Consume and troubleshoot workflows

Reading what a run did and why. Tracing a run back to its trigger, diagnosing failures from logs and history, expanding anchors and matrices in your head, finding logs and artifacts in the UI and the API, and choosing between workflow templates, reusable workflows and composite actions.

Skill
2
Exam weight
15–20%
Skills measured
January 2026
Sections
3

Interpret workflow behavior and results

Identify triggers and effects

Every run records what started it. The run summary shows Triggered via (the event — push, pull request, schedule, workflow_dispatch…) and the actor; the runs list can be filtered with event:, actor:, branch: and status:. Inside the run, github.event_name and the full payload in github.event tell a step the same thing, and run-name can put it in the list (run-name: Deploy ${{ inputs.version }} by @${{ github.actor }}).

When a question asks whether a workflow will run, walk the configuration in this order:

flowchart TD
  Q["The workflow did not run"] --> A{"Is the file on the<br/>branch the event uses?"}
  A -->|"no"| A1["schedule, workflow_dispatch, repository_dispatch,<br/>workflow_run read the default branch"]
  A -->|"yes"| B{"Do the on: filters match?"}
  B -->|"no"| B1["branches, paths and types<br/>must all match"]
  B -->|"yes"| C{"Who caused the event?"}
  C -->|"GITHUB_TOKEN"| C1["No new run, except<br/>workflow_dispatch and repository_dispatch"]
  C -->|"a fork contributor"| C2["Waiting for approval<br/>to run fork workflows"]
  C -->|"a person or an app"| D{"Is the workflow enabled?"}
  D -->|"no"| D1["Disabled by hand, or<br/>after 60 days without activity"]
  D -->|"yes"| E["Check the commit message for<br/>skip ci, and the PR for conflicts"]
Why a workflow did not run. Most surprises come from the first two boxes: an event that reads the workflow from the default branch, and filters that must all match. Commit messages containing [skip ci], [ci skip], [no ci], [skip actions] or [actions skip] skip push and pull_request runs.
EventWorkflow file and GITHUB_SHA come from
pushThe pushed commit
pull_requestThe PR's merge commit (refs/pull/<n>/merge); no run if the PR has merge conflicts
pull_request_targetThe default branch (since December 2025); runs even with conflicts
schedule, repository_dispatch, workflow_runThe default branch
workflow_dispatchThe branch or tag you choose — but the button appears only once the file is on the default branch
workflow_callThe caller's context: github is the caller's, not the called workflow's

Diagnose failed runs

Start at the run summary: the graph shows which job failed, and annotations list errors and warnings with links to the step. Then open the failed job — GitHub expands the failed step for you. Read up from the final Error: Process completed with exit code … line to the first real error.

Run npm run build
Run npm ci
npm ci
added 812 packages, and audited 813 packages in 14s
Run npm run build
> web@2.4.0 build
> tsc -p tsconfig.build.json && vite build
src/api/client.ts(18,7): error TS2322: Type 'string' is not assignable to type 'number'.
Error: Type 'string' is not assignable to type 'number'. src/api/client.ts#L18:7
Error: Process completed with exit code 2.
Symptom in the log or UIUsual causeFix
Resource not accessible by integration (403)GITHUB_TOKEN lacks a scope, or a fork PR run has a read-only tokenAdd the scope under permissions:; for forks, move the write to workflow_run
Job queued forever: Waiting for a runner to pick up this jobNo online runner matches all runs-on labels, or the group is not available to the repositoryCheck labels, runner group access and runner status
exit code 127Command not found — the tool is not on that image or not on PATHInstall it with a setup-* action, or check $GITHUB_PATH
The job running on runner … has exceeded the maximum execution timetimeout-minutes reached (default 360)Find the hang; set a realistic timeout
Invalid workflow file annotation, run fails instantlyYAML or schema error: bad indentation, unknown key, expression in a forbidden placeLint with the VS Code extension or actionlint
Unable to resolve actionTypo in owner/repo@ref, deleted tag, private action not shared, or blocked by policyCheck the ref and the organisation's allowed-actions policy
A job shows as skippedIts if: was false, or a job it needs did not succeedRead needs.<job>.result; add a status function if it must run
Step output is emptyThe step has no id, the output was never written, or it used ::set-outputWrite name=value to $GITHUB_OUTPUT
Run gh pr comment
Run gh pr comment "$PR" --body "Preview ready"
gh pr comment "$PR" --body "Preview ready"
GraphQL: Resource not accessible by integration (addComment)
Error: Process completed with exit code 1.

Run history is the other half of diagnosis. Filter the workflow's runs by branch and status to find the last green run, then compare: a different commit points at code; the same commit failing intermittently points at flakiness or an external service; every workflow failing from one moment points at a runner image, a moved action tag or an expired secret.

Debug logging

  • Step debug logging — set a secret or variable ACTIONS_STEP_DEBUG to true. Adds ##[debug] lines, including how each if: was evaluated.
  • Runner diagnostic logging — ACTIONS_RUNNER_DEBUG set to true. Adds the runner's own diagnostic logs to the log archive.
  • For one attempt only, tick Enable debug logging when re-running, or pass --debug to gh run rerun.
Deploy (skipped)
Evaluating condition for step: 'Deploy'
Evaluating: (success() && (github.ref == 'refs/heads/main'))
Evaluating And:
..Evaluating success:
..=> true
..Evaluating Equal:
....Evaluating Index:
......Evaluating github:
......=> Object
....=> 'refs/heads/feature/login'
..=> false
=> false
Result: false

That debug trace answers "why was my step skipped?" directly: github.ref was a feature branch. Write ::debug::message from your own scripts to add lines that only appear when debug logging is on.

Expand YAML anchors, aliases and merged mappings

The parser replaces every alias with a copy of its anchored node before anything else happens, so to analyse a workflow you expand it in your head. These two files are the same workflow:

.github/workflows/tests.yml excerpt
jobs:
  unit:
    runs-on: ubuntu-latest
    timeout-minutes: &short 10
    env: &node-env
      NODE_ENV: test
      CI: 'true'
    steps:
      - &install
        name: Install
        run: npm ci
      - run: npm test

  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: *short
    env: *node-env
    steps:
      - *install
      - run: npm run e2e
after expansion (excerpt)
jobs:
  unit:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      NODE_ENV: test
      CI: 'true'
    steps:
      - name: Install
        run: npm ci
      - run: npm test

  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      NODE_ENV: test
      CI: 'true'
    steps:
      - name: Install
        run: npm ci
      - run: npm run e2e
  • An anchor can label a scalar (&short 10), a mapping or a sequence item; the alias repeats it exactly.
  • An alias must come after its anchor in the file, and anchors are file-local.
  • A merged mapping (<<: *base) expands to the base's keys plus the local ones, local keys winning. Read it that way — and remember Actions documents only & and *.
  • Changing the anchored value changes every alias: in the example, raising &short lengthens both timeouts.

Interpret matrix expansions and re-run variants

A matrix job is named after its job ID and its values, in axis order: test (windows-latest, 22). A custom name: with ${{ matrix.* }} changes that label, which is also what you select in branch protection and rulesets as a required check — rename the job and the required check stops matching.

flowchart LR
  B["build"]:::success --> T1["test (ubuntu-latest, 22)"]:::success
  B --> T2["test (ubuntu-latest, 24)"]:::success
  B --> T3["test (windows-latest, 22)"]:::failure
  B --> T4["test (windows-latest, 24)"]:::failure
  T1 --> R["report<br/>needs: test"]:::skipped
  T2 --> R
  T3 --> R
  T4 --> R
Both Windows legs failed and both Ubuntu legs passed: correlate the failures to the os axis, not the node axis — a platform problem (paths, line endings, shell), not a runtime one. report was skipped because a job it needs failed.
Re-run optionWhat runs again
Re-run all jobsEvery job in the run
Re-run failed jobsThe failed jobs, plus the jobs that depend on them
Re-run this job (icon beside a job, including one matrix leg)That job and its dependants — the way to retry one flaky variant
  • Re-runs use the same commit and ref as the original (GITHUB_SHA, GITHUB_REF) and the privileges of the actor who triggered the original run, not of whoever re-runs it.
  • Possible for 30 days after the run, up to 50 re-runs per run, and not while the workflow is disabled.
  • Each attempt increments github.run_attempt; run_id and run_number stay the same. The UI keeps every attempt's logs.

Access workflow artifacts and logs

In the UI

  • Logs: Actions → workflow → run → job. Search the log, expand groups, view it raw, and download it — a single job, or the whole run as a zip from the run's ⋯ menu.
  • Artifacts: at the bottom of the run summary, with their size; download one as a zip or delete it. Anyone with read access can download; in public repositories, any signed-in user.
  • Job summaries and annotations sit on the run summary above the artifacts.
  • Caches have their own page under Actions → Caches, where they can be filtered by branch and deleted.

With the GitHub CLI and the REST API

gh
# Runs: list, filter, follow
gh run list --workflow ci.yml --branch main --status failure --limit 5
gh run view 987654321                      # summary, jobs, annotations, artifacts
gh run view 987654321 --log-failed         # only the logs of failed steps
gh run view --job 1122334455 --log         # one job's full log
gh run watch 987654321                     # live progress until it ends

# Re-run
gh run rerun 987654321 --failed            # failed jobs and their dependants
gh run rerun --job 1122334455 --debug      # one job, with debug logging

# Artifacts
gh run download 987654321 --name web-dist --dir ./dist
gh run download 987654321 --pattern 'coverage-*'

# Workflows
gh workflow list --all                     # including disabled ones
gh workflow run deploy.yml --ref main -f target=staging -f version=v2.4.0
gh workflow disable nightly.yml
gh workflow enable nightly.yml
REST endpoints
# Runs, for the repository or for one workflow (by file name or ID)
GET /repos/{owner}/{repo}/actions/runs?branch=main&status=failure
GET /repos/{owner}/{repo}/actions/workflows/ci.yml/runs

# A run, its jobs, and its logs (a 302 redirect to a short-lived zip URL)
GET /repos/{owner}/{repo}/actions/runs/{run_id}
GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs
GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs
GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs

# Artifacts: list, inspect, download (zip), delete
GET    /repos/{owner}/{repo}/actions/artifacts?name=web-dist
GET    /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts
GET    /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip
DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id}

# Re-run and cancel
POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs
POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel

Log and artifact downloads are redirects

The …/logs and …/zip endpoints answer with a 302 to a short-lived URL; follow the redirect promptly (curl -L). They need read access — actions: read for GITHUB_TOKEN, or a token with the Actions read permission.

Download and manage artifacts

  • Inside a workflow, actions/download-artifact@v8 fetches artifacts from the same run by default; with run-id and a github-token that has actions: read, it can fetch from another run or repository.
  • Download by name, by pattern (with merge-multiple to flatten), by artifact-ids, or everything if you pass nothing.
  • Artifacts count towards the account's Actions storage until they expire or are deleted; deleting artifacts early is how you free storage before the retention period ends.
  • Expired artifacts cannot be recovered. The artifact list keeps a record that it existed.

Use and manage workflow templates

Starter workflows are now workflow templates

The study guide still says starter workflows; the GitHub UI and documentation say workflow templates. GitHub's own collection still lives in the actions/starter-workflows repository. Treat the two names as the same thing.

Consume public and organisation templates

Actions → New workflow lists templates: GitHub's own, suggested by the languages it detects in the repository, and — under a heading named after your organisation — your organisation's templates. Choosing one opens the file in the editor; you adapt it, then commit it to .github/workflows/ like any other file.

Organisation and non-public templates

An organisation publishes templates from its .github repository, in a workflow-templates/ directory: each template is a workflow file plus a metadata file with the same base name and .properties.json.

.github/workflow-templates/node-ci.yml
name: Octo Org CI

on:
  push:
    branches: [$default-branch]      # replaced with the repository's default branch
  pull_request:
    branches: [$default-branch]

permissions:
  contents: read

jobs:
  ci:
    uses: octo-org/platform/.github/workflows/node-ci.yml@v3   # the logic stays central
.github/workflow-templates/node-ci.properties.json
{
  "name": "Octo Org Node.js CI",
  "description": "The organisation's standard build, test and scan for Node.js services.",
  "iconName": "octicon package",
  "categories": ["JavaScript", "TypeScript"],
  "filePatterns": ["^package\\.json$"]
}
  • name and description are required; iconName (an SVG in the same directory, or octicon <name>), categories and filePatterns (regular expressions matched against root files) are optional.
  • $default-branch is replaced with the repository's default branch when the template is used.
  • A non-public template lives in a private or internal .github repository and is offered to the organisation's own repositories. Anything private it calls — a reusable workflow or an action in another private repository — must be shared through that repository's Settings → Actions → General → Access.
  • Anyone in the organisation with permission to create workflows in a repository can use its templates.

Template plus reusable workflow

The template above is three lines of real logic: it calls a reusable workflow. The template gets teams started; the reusable workflow keeps their pipelines in step after that. That combination is the usual answer when a question asks for both "easy to adopt" and "centrally maintained".

Templates, reusable workflows and composite actions

flowchart LR
  TPL["Workflow template<br/>.github repo, workflow-templates/"] -->|"copied once"| WF["Your workflow<br/>.github/workflows/ci.yml"]
  WF -->|"job level: jobs.id.uses"| RW["Reusable workflow<br/>on: workflow_call"]
  RW -->|"step level: steps.uses"| CA["Composite action<br/>runs.using: composite"]
Three ways to share automation. A template is copied once and then forgotten; a reusable workflow is called as a job at run time; a composite action is called as a step inside any job, including the jobs of a reusable workflow.
Workflow templateReusable workflowComposite action
What it isA scaffold copied into a repositoryA workflow with on: workflow_callAn action whose runs.using is composite
Used atCreation time, onceJob level: jobs.<id>.usesStep level: steps[*].uses
After an updateExisting copies do not changeEvery caller on that ref gets itEvery caller on that ref gets it
Can containAnything a workflow canSeveral jobs, each with its own runner, environments and servicesSteps only, on the caller's runner
Secrets—Passed explicitly or with secrets: inheritOnly through inputs
Logs showA normal workflowEach called job separatelyOne step (sub-steps folded inside it)
Choose it toHelp teams start consistentlyEnforce a whole pipeline centrallyPackage a few steps to reuse inside jobs

Disable or delete a workflow

DisableDelete
HowWorkflow's ⋯ → Disable workflow, gh workflow disable, or PUT …/workflows/{workflow_id}/disableRemove the file from the branch (a commit), then optionally delete its runs
FileStays in the repository, unchangedGone from that branch
TriggersNone fire until re-enabled; manual runs and re-runs are blocked tooNone fire from that branch
HistoryKeptKept until the runs are deleted or expire; the workflow stays in the list while runs remain
ReversibleYes — enable it againOnly by restoring the file
Use whenPausing temporarily: an outage, a noisy schedule, an investigationThe workflow is obsolete
  • GitHub disables scheduled workflows in public repositories after 60 days without repository activity; enable them again from the Actions tab.
  • Workflows in a fork are disabled until someone enables Actions in the fork.
  • Disabling Actions for the whole repository (Settings → Actions → General) is a different, broader switch.

Disable beats delete when you need the history

If a question wants to stop a workflow "temporarily", or "without losing its configuration or run history", the answer is disable. Deleting the file stops it from that branch only — a branch that still has the file can still trigger it — and is a code change that needs a commit and review.

Next: the actions themselves — Skill 3, Author and maintain actions.