SaveMyCert
Log in
5 of 5 free questions left today·for 30 a day
Author and manage workflows

GitHub Actions Workflow Structure: Jobs, Conditions, Matrices and Contexts (GH-200)

23 min readGH-200 · Author and manage workflowsUpdated

Workflow structure is how a GitHub Actions workflow is organised into jobs and steps, how those jobs depend on each other and decide whether to run, and which data (contexts, expressions, environment variables) each part can read. This lesson covers the GitHub Actions (GH-200) skills for designing that structure: if conditions and status check functions, needs dependencies, workflow commands and environment variables, service containers, matrix strategies with include, exclude, fail-fast and max-parallel, runner image changes, YAML anchors and aliases, context availability, expression evaluation and secret safety, action version pinning, and the VS Code extension for authoring workflows.

What you’ll learn
  • Write job and step if conditions, including status check functions, operator precedence, the right github ref fields and skipping a required job without blocking merges
  • Order jobs with needs and predict which downstream jobs run or are skipped after a failure
  • Use workflow commands for annotations, log groups, masking and stopping command processing, and scope environment variables correctly
  • Configure service containers with ports, health checks and the right host name for container and runner jobs
  • Design a matrix with include, exclude, fail-fast, continue-on-error and max-parallel, pass outputs from matrix jobs, and keep it working through runner image changes
  • Choose the contexts and evaluation point each workflow key allows, and prevent script injection and secret leakage
  • Reuse configuration with YAML anchors and aliases, pin action versions safely (including under the SHA-pinning policy) and use the VS Code extension to validate workflows

Jobs, steps and conditional logic

A workflow is made of jobs, and each job is a list of steps that run in order on one runner. Each step either runs a shell script (run:) or calls an action (uses:). Jobs run in parallel by default; steps inside a job always run one after another and share the runner's file system.

The if: key decides whether a job or a step runs. Its value is an expression, and GitHub evaluates it as one even when you leave out the ${{ }} wrapper. A skipped job or step reports the result skipped; it is not a failure.

Skipping a required check. A job skipped by its own if: reports Success to a required status check, so it does not block a pull request. A whole workflow that never starts because of a branches/paths filter or a skip instruction in the commit message creates no run, so its required checks stay Pending and the merge is blocked. To let some pull requests skip a required job, keep the workflow triggered and skip the job instead: a small first job (for example, one that inspects the changed file list) writes an output, and the required job lists that job in needs: and uses a job-level if: on the output, such as if: needs.detect.outputs.run_tests == 'true'. When the condition is false the job is skipped, reports Success, and the pull request can merge. The full comparison is in the lesson on troubleshooting workflow runs.

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Build docs site
        if: ${{ !endsWith(github.ref_name, '-draft') }}
        run: make docs
      - name: Release
        if: github.ref == 'refs/heads/release' && github.event_name == 'push'
        run: make release

Details that decide whether a condition works:

  • A leading ! needs ${{ }} (or quotes). The YAML parser reads the file before GitHub evaluates anything, and an unquoted value that starts with ! is a YAML tag, so the file fails to load. Wrapping the whole expression in ${{ }} fixes it. There is no not keyword.
  • Operators: !, ==, !=, <, >, &&, || and parentheses. && binds tighter than ||, so a && b || c means (a && b) || c. Add parentheses whenever you combine both.
  • String literals use single quotes, and string comparison ignores case.
  • Equality is loose. When the two sides have different types, GitHub converts both to numbers. Job and step outputs are always strings, so needs.scan.outputs.found == true compares the string 'true' (which becomes NaN) with the boolean true (which becomes 1) and is never true. Compare with the string 'true', or convert with fromJSON().
  • Useful functions: contains(), startsWith(), endsWith(), format(), join(), toJSON(), fromJSON(), hashFiles(), plus the status check functions in the next section.

Branch conditions depend on which github field you test:

FieldPush to mainPull request into main
github.refrefs/heads/mainrefs/pull/<number>/merge
github.ref_namemain (short name)<number>/merge
github.base_refemptymain (target branch)
github.head_refemptythe source branch

So github.ref == 'refs/heads/main' matches pushes to main but not pull requests that target main, and comparing ref_name with a full refs/heads/… value never matches. A tag push sets github.ref to refs/tags/<tag>.

Job dependencies with needs and status check functions

The needs: key makes a job wait for other jobs: needs: [build, test] starts the job only after both have completed successfully. Jobs without needs start at the same time. needs orders jobs; it is not the same as concurrency, which serialises whole runs or jobs that share a group name, or an environment, which adds deployment protection.

Skipping propagates. If a needed job fails or is skipped, every job that needs it is skipped too, and so is everything further down the chain, unless the dependent job's if contains a status check function. That is because an if without a status function gets an implied success() check, and success() is false once anything upstream did not succeed.

FunctionTrue whenTypical use
success()No previous step (or ancestor job) failed or was cancelled. Implied when if has no status function.The default behaviour
failure()A previous step failed; for a job, any ancestor job failed, not only the direct dependencyNotify or clean up after a failure
cancelled()The workflow run was cancelledClean-up after a cancellation
always()Always, even when the run is cancelledWork that must happen no matter what
!cancelled()Anything except cancellation (same as success() || failure())Upload reports after passing or failing tests; GitHub recommends it over always()

To react to one specific job, combine a status function that turns off the implicit check with that job's result. needs.<job>.result is success, failure, cancelled or skipped:

jobs:
  restore-db:
    needs: migrate
    # only when migrate itself failed, not when an earlier job failed and migrate was skipped
    if: always() && needs.migrate.result == 'failure'
    runs-on: ubuntu-latest
    steps:
      - run: make restore
  changelog:
    needs: lint
    # lint is skipped on scheduled runs; run after success or skip, never after cancel
    if: ${{ !cancelled() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') }}
    runs-on: ubuntu-latest
    steps:
      - run: make changelog

Two traps: failure() || needs.migrate.result == 'failure' also fires when an earlier job (one that migrate depends on) fails, because failure() looks at all ancestors; and a condition that only tests needs.migrate.result can never fire on a failure, because the implied success() is false first.

The needs context holds direct dependencies only. If release needs compile and compile needs prepare, release cannot read needs.prepare.outputs.bucket. List prepare too (needs: [prepare, compile]), which keeps the wait for compile and exposes prepare's outputs and result. How a job declares outputs and passes values with GITHUB_OUTPUT is covered in the lesson on workflow execution and outputs.

Workflow commands and environment variables

Workflow commands are specially formatted lines a step prints to standard output (::command parameters::value); the runner reads them to create annotations, fold log lines, mask values or pause command processing.

CommandEffect
::notice::, ::warning::, ::error::Create an annotation of that severity. Optional parameters file, line, endLine, col, endColumn and title attach it to a place in the code, so it also shows next to that line in a pull request's diff. Without parameters it is attached to the run only.
::debug::A debug message, shown only when step debug logging is enabled (ACTIONS_STEP_DEBUG set to true)
::group::title … ::endgroup::Folds the lines between them into an expandable group in the log
::add-mask::valueRegisters a value so the runner replaces it with *** in the rest of the job's log
::stop-commands::token … ::token::Stops processing workflow commands until the same unique token is printed again, so untrusted output that happens to start with :: is logged as plain text
- run: |
    echo "::error file=src/db.py,line=40,title=Typecheck::Missing return type"
    TOKEN="$(./get-token.sh)"
    echo "::add-mask::$TOKEN"
    STOP="$(uuidgen)"
    echo "::stop-commands::$STOP"
    ./third-party-generator      # its '::' lines are not treated as commands
    echo "::$STOP::"

Parameter names are exact: path or ln are ignored. Environment files such as GITHUB_ENV, GITHUB_OUTPUT and GITHUB_STEP_SUMMARY are the file-based relatives of these commands; their data-passing use belongs to the execution-and-outputs lesson, but one rule matters here: a variable written to GITHUB_ENV is available to later steps, not to the step that wrote it.

Environment variables are set with env: at three levels. Workflow-level env applies to every job, job-level env overrides it for that job, and step-level env overrides both for that step: the most specific definition wins.

The runner also sets default variables such as GITHUB_SHA, GITHUB_REF, GITHUB_WORKSPACE, RUNNER_OS and RUNNER_TEMP. They exist in the shell on the runner, but they are not in the env context, so env.RUNNER_OS in an expression is empty. In expressions use the matching context property instead (github.sha, github.ref, runner.os, runner.temp); in scripts read the variable ($GITHUB_SHA).

Service containers for databases and queues

Service containers are Docker containers that a job declares under jobs.<job_id>.services to provide a dependency such as PostgreSQL, Redis or a message queue. The runner creates a Docker network, starts each service before the job's steps, and removes the containers and network when the job finishes, with no docker commands in your steps. Service containers need a Linux runner: a GitHub-hosted Ubuntu runner, or a self-hosted Linux runner with Docker installed.

Do not confuse services with container: container: runs the job's own steps inside an image, while services: runs extra containers next to the job. How the steps reach a service depends on where the steps run:

Steps run…Host name to usePorts
In a container (container: set)The service's label, for example redisAll ports are reachable on the shared network; no mapping needed. localhost means the job's own container.
Directly on the runnerlocalhostMust be mapped with ports:. '6379:6379' fixes the host port; '6379' alone lets Docker pick a free host port, which you read from job.services.<id>.ports[6379].
jobs:
  test:
    runs-on: ubuntu-latest
    container: python:3.13
    services:
      redis:
        image: redis:7
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - run: pytest tests/queue
        env:
          REDIS_HOST: redis    # the service label, because the job runs in a container

Other service keys: image, env, ports, volumes, credentials (for a private registry) and options, which passes extra docker create options. Health checks (--health-cmd with interval, timeout and retries) make the runner wait until the service reports healthy before running steps, so tests do not hit a database that is still starting. That is the reliable alternative to a fixed sleep. The --network option is not supported, and needs applies to jobs, not to services.

Matrix strategies: include, exclude, fail-fast and max-parallel

A matrix strategy (jobs.<job_id>.strategy.matrix) turns one job definition into one job per combination of its variables, such as operating systems and runtime versions. Each job reads its values from the matrix context, and a matrix can generate at most 256 jobs per workflow run.

jobs:
  test:
    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.experimental == true }}
    strategy:
      fail-fast: true
      max-parallel: 3
      matrix:
        os: [ubuntu-24.04, macos-latest]
        python: ['3.11', '3.12', '3.13']
        exclude:
          - os: macos-latest
            python: '3.11'
        include:
          - python: '3.13'
            lint: true
          - os: ubuntu-24.04
            python: '3.14'
            experimental: true
    steps:
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python }}

The rules for shaping the matrix:

  • Exclude first. exclude entries remove combinations, and a partial match is enough ({ os: macos-latest } removes every macOS job). Excluded combinations never get a runner, which is what saves minutes; a job-level if cannot do this, because it cannot read matrix.
  • Then include. For each include entry, its keys are added to every combination it matches without overwriting an original matrix value. { python: '3.13', lint: true } adds lint to both Python 3.13 jobs and the job count stays the same. An entry that matches no combination because it would overwrite an original value (such as python: '3.14') becomes a new job. An entry with only new keys matches every combination.
  • Extending an axis multiplies. Adding a value to python adds a job for every OS; to add one combination only, use one include entry with all its keys (including the one runs-on needs).
  • Include-only matrices. A matrix with no variables other than include creates exactly one job per entry, the shortest way to run a few hand-picked combinations instead of a large cross product trimmed by many exclude entries.

Controlling failures and concurrency:

SettingWhat it does
fail-fast (default true)When any matrix job fails, GitHub cancels the in-progress and queued jobs of that matrix. false lets the rest finish, but the run still fails.
continue-on-error (on the job)That job may fail without cancelling the others and without failing the run. Driven by a matrix value (for example matrix.experimental), it applies only to the experimental variants, which still run every time.
max-parallelCaps how many matrix jobs run at once; every job still runs. By default, as many run as runner capacity allows.
concurrency (job or workflow)Allows one running and one pending job per group name and cancels or replaces the others, so it does not throttle a matrix to N at a time

Outputs from a matrix job. A job that runs as a matrix still declares one outputs: map, and GitHub combines the outputs of all its matrix jobs by name. If every matrix job writes the same output name, the last matrix job that runs overrides the value written by the others, and GitHub does not guarantee the order in which matrix jobs run, so a downstream job sees only one value and cannot tell which job produced it. Give each matrix job its own output name and declare every name:

build:
  strategy:
    matrix:
      os: [linux, windows]
  outputs:
    artifact_linux: ${{ steps.pkg.outputs.artifact_linux }}
    artifact_windows: ${{ steps.pkg.outputs.artifact_windows }}
  steps:
    - id: pkg
      run: echo "artifact_${{ matrix.os }}=$(./package.sh)" >> "$GITHUB_OUTPUT"

A job with needs: build then reads needs.build.outputs.artifact_linux and needs.build.outputs.artifact_windows. The cause is the shared name, so the fix is unique names: changing how many matrix jobs run at once leaves the name shared, and every output, including a combined one, is a single string rather than a collection of each job's value.

Optimising for cost: test the full cross product only where it finds real bugs, exclude combinations you do not support, add one-off variants with include, and use max-parallel when a shared resource (a staging API, a licence) limits concurrent clients. Reading an expanded matrix from run logs and re-running individual matrix jobs is a troubleshooting skill covered in the Consume and troubleshoot workflows domain.

Runner labels and runner image changes

The runs-on label picks the GitHub-hosted runner image, and -latest labels move: GitHub points them at a newer operating system image when it migrates. windows-latest moved to Windows Server 2025, and GitHub is moving ubuntu-latest from Ubuntu 24.04 to Ubuntu 26.04 in a gradual rollout between 19 October and 19 November 2026. During a rollout like that, the same label can resolve to the old image on one run and the new image on the next, so a job can pass and then fail with no change to the workflow. A new image can have different preinstalled software, so a build can break the day a label moves even though nobody changed the workflow.

  • Pin a version label to control upgrades. windows-2022, ubuntu-24.04 or ubuntu-26.04 keep the same image family until you choose to move, which buys time to fix the build for the new image without managing your own runners. To adopt the new image early, pin its version label (for example ubuntu-26.04) on a test job before the -latest label moves.
  • Retired images stop running. GitHub retired the Ubuntu 20.04 image (and Windows Server 2019) in 2025, so jobs that request ubuntu-20.04 are no longer assigned a GitHub-hosted runner. Re-running does not help; change the label to a supported image and test on it.
  • Branch on the OS, not the label. In steps, if: runner.os == 'Windows' (values Linux, Windows, macOS) keeps working when a label is pinned or renamed, whereas matrix.os == 'windows-latest' breaks.

The current list of images and their labels is published in the actions/runner-images repository and in GitHub's documentation for GitHub-hosted runners; check it before relying on a specific version.

YAML anchors and aliases

YAML anchors and aliases let a workflow define a block once and repeat it within the same file: &name marks (anchors) a node, and *name (an alias) inserts a complete copy of it elsewhere. GitHub Actions supports them on all workflows.

jobs:
  style:
    runs-on: ubuntu-latest
    env: &check-env
      PYTHONWARNINGS: error
      TZ: UTC
    steps:
      - run: make style
  types:
    runs-on: ubuntu-latest
    env: *check-env         # PYTHONWARNINGS=error and TZ=UTC
    steps:
      - run: make types
  • Resolved at parse time. Anchors are a YAML feature, expanded when the file is parsed, before any expression or job runs. The result is exactly as if the content had been written out in both places.
  • The anchor must come first. An alias can only refer to an anchor defined earlier in the file. Put the anchor on the first occurrence; needs or ${{ }} cannot help, because they act after parsing.
  • Single file only. Aliases name anchors in the same file, not paths such as *release.steps. To share configuration across files, use a reusable workflow or an action (lessons in other domains); to share values across workflows, use configuration variables.
  • Anchor any node: an env mapping, a list of steps or a whole job.

Merge keys (<<). The exam's skill list also names the YAML merge key, which merges an anchored mapping into another mapping and lets locally written keys override the merged ones:

defaults: &defaults
  timeout-minutes: 10
  runs-on: ubuntu-latest
# in another mapping:
<<: *defaults
timeout-minutes: 30   # overrides the merged value

Know what the syntax means so you can expand it when reading a configuration. GitHub's own documentation describes anchors and aliases but does not document merge-key support, so validate a workflow that relies on << (for example with the editor extension or a test branch) rather than assuming it loads.

Contexts and where each one is available

Contexts are the objects expressions read: github (event, ref, sha, repository, actor; github.event is the full webhook payload), env, vars (configuration variables), secrets, inputs, matrix, needs, strategy, job, steps and runner. Each workflow key accepts only some of them, because some keys are evaluated by GitHub before the job reaches a runner, and the runner-side contexts do not exist yet. Using a context where it is not allowed makes the workflow invalid.

KeyContexts available
Workflow-level envgithub, secrets, inputs, vars
jobs.<id>.ifgithub, needs, vars, inputs (no secrets, env, matrix or runner)
jobs.<id>.envgithub, needs, strategy, matrix, vars, secrets, inputs (no runner, no env)
jobs.<id>.runs-on, continue-on-error, servicesgithub, needs, strategy, matrix, vars, inputs
steps.ifgithub, needs, strategy, matrix, job, runner, env, vars, steps, inputs (no secrets)
steps.env, steps.with, steps.rungithub, needs, strategy, matrix, job, runner, env, vars, secrets, steps, inputs

Consequences worth knowing:

  • Secrets cannot be tested in any if. To run steps only when a secret is set, map it into env (job or step level) and test if: env.SIGNING_KEY != '' on the steps; a job-level if cannot read env. Turning the secret into a configuration variable would expose it.
  • runner.temp is not allowed in job-level env; use it in a step's env, or read $RUNNER_TEMP in the script.
  • A job-level if cannot read matrix; filter combinations with exclude.

Runtime contexts that steps rely on:

  • steps.<id>.outputs, steps.<id>.outcome and steps.<id>.conclusion (only for steps with an id). outcome is the result before continue-on-error is applied; conclusion is after. A failing step with continue-on-error: true has outcome failure but conclusion success, and failure() and job.status also see success, so only outcome reveals the error.
  • strategy.job-index (zero-based position of this matrix job), strategy.job-total, strategy.fail-fast, strategy.max-parallel. job-index is the natural shard number; github.run_number and github.run_attempt are the same for every job in the run.
  • runner.os, runner.arch, runner.temp, runner.name, runner.environment (github-hosted or self-hosted).
  • job.status, job.container and job.services.<id>.ports (the host port mapped to each service port).

Expression evaluation and preventing secret leakage

Expressions in ${{ }} are evaluated before the thing that uses them runs, and the result is pasted in as text. That explains three behaviours that otherwise look like bugs:

  • Static versus runtime evaluation. The YAML (including anchors and aliases) is parsed and validated when the workflow is loaded, so a YAML error or a context used where it is not allowed fails the run before any job starts. Job-level keys (if, runs-on, strategy, job env) are evaluated by GitHub when the job is scheduled. Step-level expressions are evaluated on the runner just before each step, which is why only they can see steps, runner and env.
  • Expressions in run: are substituted into the script before the shell starts. In a step that writes RELEASE_TAG=v3.0.0 to GITHUB_ENV and then echoes ${{ env.RELEASE_TAG }}, the expression was already replaced (with nothing) before the script ran. Within that step, read the value you just computed from a shell variable; a later step sees it both as ${{ env.RELEASE_TAG }} and as $RELEASE_TAG.
  • Script injection. Because substitution produces script text, an attacker-controlled value such as github.event.pull_request.title, an issue body or a branch name becomes part of the command. A title like a"; curl … | sh # runs on the runner, which is especially dangerous in pull_request_target workflows that hold secrets. Quoting, toJSON() or masking do not help, because the value is still inside the generated script.
# vulnerable
- run: ./label-issue.sh "${{ github.event.issue.title }}"
# safe: the shell receives data, not code
- env:
    ISSUE_TITLE: ${{ github.event.issue.title }}
  run: ./label-issue.sh "$ISSUE_TITLE"

The two safe patterns are: put the untrusted value in an environment variable and reference it as a shell variable, or pass it to an action as a with: input, so no script is generated from it.

How secret redaction works and where it fails. The runner replaces every registered secret value with *** in the logs, wherever that exact string appears: passing the secret to an action, renaming the variable that holds it or turning on debug logging still prints only the masked form. But redaction matches exact strings, so these are not masked automatically:

  • an encoded form (Base64, URL-encoded) of a secret;
  • a new value derived from a secret, such as a short-lived token obtained or signed with it;
  • one field of a structured secret (JSON, XML or YAML blob) read with fromJSON().

Register derived values with ::add-mask:: as soon as they exist, and follow GitHub's guidance to never store structured data as a secret: create one secret per sensitive value (for example DB_PASSWORD) so each is a registered, redacted value in every workflow. Encoding a blob, or moving it to an environment secret, does not make its fields redacted.

Action version pinning, immutable releases and editor tooling

The reference after @ in uses: decides whether a later run can execute different code. A branch (@main) moves with every push. A tag (@v5, even @v2.3.1) can be moved or deleted and re-created; maintainers routinely move major-version tags to the newest release. A full-length commit SHA identifies exactly one commit, and a container image digest (docker://alpine@sha256:…) one image. A local action (./.github/actions/lint) comes from the same commit as the workflow, so the same commit always runs the same code.

ReferenceCan change under you?
owner/action@mainYes, on every push
owner/action@v5 or @v5.1.0Yes, unless the repository uses immutable releases
owner/action@<40-character SHA>No
./local-action, docker://image@sha256:…No

GitHub's secure-use guidance is to pin third-party actions to a full-length commit SHA (verifying that it comes from the action's repository, not a fork) and to pin to a tag only if you trust the creator. Immutable releases change the picture for tags: when a repository enables them, a published release's Git tag is locked to its commit and its assets cannot be changed, so that release tag cannot be moved. When exam wording mentions immutable actions, it means this guarantee: an action version that cannot change after it is published. GitHub first previewed it as actions published as immutable packages and now delivers it through immutable releases. GitHub's guidance also describes a dependency lockfile, which records the exact commit of every action including transitive ones, as the strongest protection for repositories that adopt it.

The SHA-pinning policy. An enterprise, organization or repository administrator can turn on the Actions setting that requires actions to be pinned to a full-length commit SHA. Then every action used in a step must be pinned that way, including actions authored by GitHub (such as actions/checkout) and actions from your own organization; references by tag, branch or short SHA fail. Reusable workflows called by a job can still be referenced by tag. Allow and block patterns, the local-action exemption and how this policy interacts with a lockfile are covered in the lesson on enterprise governance.

Editor tooling. The GitHub Actions extension for Visual Studio Code is GitHub's authoring aid for workflow files. As you type it provides syntax highlighting for workflows and expressions; validation and completion (IntelliSense) of the workflow schema, expressions and contexts, including job and step outputs; hover documentation for keys, expression functions and event payloads; and it parses the inputs and outputs of referenced actions and reusable workflows so their with: keys are completed and checked. It can also list workflow runs and logs and manage secrets, variables and environments. It does not run jobs locally, rewrite references to SHAs or estimate billable minutes. Generic YAML schema support (a YAML language extension with the workflow JSON schema) gives basic key completion and validation in other editors.

Tip. Expect scenario items, often with a short YAML snippet, that ask what a workflow does or which change fixes it under a stated constraint. Dependency and condition items ask which jobs run or are skipped after a failure, and which if expression reacts to exactly one job's outcome without firing on cancellation or on an earlier failure. Context items test where each context is allowed (secrets in conditions, matrix or runner in job-level keys), env precedence, and outcome versus conclusion. Matrix items ask which include or exclude entry produces exactly the required jobs, how fail-fast, continue-on-error and max-parallel combine or differ from concurrency, and how to pass outputs from several matrix jobs. Required-check items contrast a job skipped by if with a workflow skipped by a filter. Service-container items hinge on host name, port mapping and health checks. Security items test script injection through ${{ }}, which values redaction misses, which uses: references can change, and what the SHA-pinning policy accepts. Distractors are usually real Actions features applied in the wrong place, so check each option against where it is evaluated and what it actually controls.

Key takeaways
  • An if without a status function implies success(); use always() or !cancelled() plus needs.<job>.result to run after an upstream failure or skip, and remember && binds tighter than ||.
  • A leading ! in an if must be inside ${{ }} (or quoted), because YAML reads an unquoted ! as a tag.
  • Jobs run in parallel unless needs orders them; a failed or skipped needed job skips its dependents, and the needs context holds direct dependencies only.
  • A job skipped by its if reports Success to a required check, but a workflow skipped by a path or branch filter or a commit-message skip leaves the check Pending; skip the job, not the workflow.
  • Workflow commands (::warning file=,line=::, ::group::, ::add-mask::, ::stop-commands::) are lines printed to stdout; a GITHUB_ENV value reaches later steps only.
  • The most specific env definition wins; default GITHUB_* and RUNNER_* variables are in the shell, not in the env context — use the github and runner context properties in expressions.
  • Service containers run on Linux runners; container jobs reach them by label, runner jobs via localhost and mapped ports; --health-cmd options make the runner wait for readiness.
  • exclude removes partial matches first, include then adds keys or new combinations without overwriting originals; fail-fast cancels the matrix, continue-on-error tolerates one variant, max-parallel caps concurrency; matrix jobs that share an output name overwrite each other, so give each one a unique output name.
  • -latest labels move (windows-latest is Windows Server 2025; ubuntu-latest moves from 24.04 to 26.04 in a gradual rollout from 19 October 2026; ubuntu-20.04 is retired); pin a version label to control upgrades and test runner.os instead of the label.
  • jobs.<id>.if sees only github, needs, vars and inputs; secrets are never allowed in if, so map them into env and test env in step conditions.
  • ${{ }} is substituted into scripts before they run, so pass untrusted input through env or action inputs; encoded, derived and structured secret values are not redacted, and only a full commit SHA (or an immutable release) fixes the action code that runs.

Frequently asked questions

Why does a GitHub Actions job not run after an earlier job fails, even with an if condition?

Any if condition that does not contain a status check function gets an implied success() check, and success() is false once a needed job (or one of its ancestors) failed or was skipped. Add a status function such as always() or !cancelled() and combine it with needs.<job>.result, for example always() && needs.migrate.result == 'failure', to run after a specific outcome.

What is the difference between steps.<id>.outcome and steps.<id>.conclusion?

outcome is a step's result before continue-on-error is applied, and conclusion is the result after it. For a failing step with continue-on-error: true, outcome is failure but conclusion is success, so a later step that should react to that failure must test steps.<id>.outcome == 'failure'.

How do include and exclude work in a GitHub Actions matrix?

exclude is applied first and removes every combination that matches its entry, even partially. include is applied next: each entry adds its extra keys to every combination it matches without overwriting the original matrix values, and an entry that cannot be added to any combination becomes a new job. A matrix with only include entries creates exactly one job per entry.

Why can't I use secrets in a GitHub Actions if condition?

The secrets context is not available in job-level or step-level if keys. The documented pattern is to assign the secret to an environment variable in the job or step env, then test that variable in a step condition such as if: env.SIGNING_KEY != ''. A job-level if cannot read env, so the check has to be on the steps.

How do I connect to a service container in GitHub Actions?

If the job's steps run inside a container, connect to the service by its label (for example redis) on its normal port, because both are on the same Docker network. If the steps run directly on the runner, map the port with ports and connect to localhost; when only the container port is listed, read the chosen host port from job.services.<id>.ports. Add --health-cmd options so the runner waits until the service is ready.

How do I stop script injection in a GitHub Actions run step?

Never place untrusted values such as github.event.issue.title directly inside ${{ }} in a run script, because the expression is substituted into the script text before the shell runs. Assign the value to an environment variable in the step's env and use the shell variable (for example "$ISSUE_TITLE"), or pass it to an action as a with: input.

Can YAML anchors be used across GitHub Actions workflow files?

No. Anchors (&name) and aliases (*name) are a YAML feature resolved when one file is parsed, and an alias must come after its anchor in that file. To share configuration across files, use a reusable workflow or an action; to share values, use configuration variables.

Should GitHub Actions be pinned to a tag or a commit SHA?

A full-length commit SHA guarantees the exact code that runs, because branches move and tags can be moved or re-created. GitHub recommends SHA pinning for third-party actions and tag pinning only for creators you trust. A tag from a repository that uses immutable releases is locked to its commit, and a dependency lockfile, where adopted, records the exact commit of every action. When an administrator enables the policy that requires full-length SHA pinning, it applies to every action used in a step, including GitHub-authored and same-organization actions, while reusable workflows can still be called by tag.

Source

This lesson covers the "Author and manage workflows" domain of the official GH-200 exam guide. Vendors revise their guides — check the source for the current version.

Test yourself on this topic
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?