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"] | Event | Workflow file and GITHUB_SHA come from |
|---|---|
push | The pushed commit |
pull_request | The PR's merge commit (refs/pull/<n>/merge); no run if the PR has merge conflicts |
pull_request_target | The default branch (since December 2025); runs even with conflicts |
schedule, repository_dispatch, workflow_run | The default branch |
workflow_dispatch | The branch or tag you choose — but the button appears only once the file is on the default branch |
workflow_call | The 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 ci
Run npm run build
| Symptom in the log or UI | Usual cause | Fix |
|---|---|---|
Resource not accessible by integration (403) | GITHUB_TOKEN lacks a scope, or a fork PR run has a read-only token | Add the scope under permissions:; for forks, move the write to workflow_run |
| Job queued forever: Waiting for a runner to pick up this job | No online runner matches all runs-on labels, or the group is not available to the repository | Check labels, runner group access and runner status |
exit code 127 | Command not found — the tool is not on that image or not on PATH | Install it with a setup-* action, or check $GITHUB_PATH |
| The job running on runner … has exceeded the maximum execution time | timeout-minutes reached (default 360) | Find the hang; set a realistic timeout |
| Invalid workflow file annotation, run fails instantly | YAML or schema error: bad indentation, unknown key, expression in a forbidden place | Lint with the VS Code extension or actionlint |
| Unable to resolve action | Typo in owner/repo@ref, deleted tag, private action not shared, or blocked by policy | Check the ref and the organisation's allowed-actions policy |
| A job shows as skipped | Its if: was false, or a job it needs did not succeed | Read needs.<job>.result; add a status function if it must run |
| Step output is empty | The step has no id, the output was never written, or it used ::set-output | Write name=value to $GITHUB_OUTPUT |
Run gh pr comment "$PR" --body "Preview ready"
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_DEBUGtotrue. Adds##[debug]lines, including how eachif:was evaluated. - Runner diagnostic logging —
ACTIONS_RUNNER_DEBUGset totrue. Adds the runner's own diagnostic logs to the log archive. - For one attempt only, tick Enable debug logging when re-running, or pass
--debugtogh run rerun.
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:
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 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
&shortlengthens 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
| Re-run option | What runs again |
|---|---|
| Re-run all jobs | Every job in the run |
| Re-run failed jobs | The 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_idandrun_numberstay 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
# 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 # 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@v8fetches artifacts from the same run by default; withrun-idand agithub-tokenthat hasactions: read, it can fetch from another run or repository. - Download by
name, bypattern(withmerge-multipleto flatten), byartifact-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.
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 {
"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$"]
} nameanddescriptionare required;iconName(an SVG in the same directory, orocticon <name>),categoriesandfilePatterns(regular expressions matched against root files) are optional.$default-branchis replaced with the repository's default branch when the template is used.- A non-public template lives in a private or internal
.githubrepository 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"]
| Workflow template | Reusable workflow | Composite action | |
|---|---|---|---|
| What it is | A scaffold copied into a repository | A workflow with on: workflow_call | An action whose runs.using is composite |
| Used at | Creation time, once | Job level: jobs.<id>.uses | Step level: steps[*].uses |
| After an update | Existing copies do not change | Every caller on that ref gets it | Every caller on that ref gets it |
| Can contain | Anything a workflow can | Several jobs, each with its own runner, environments and services | Steps only, on the caller's runner |
| Secrets | — | Passed explicitly or with secrets: inherit | Only through inputs |
| Logs show | A normal workflow | Each called job separately | One step (sub-steps folded inside it) |
| Choose it to | Help teams start consistently | Enforce a whole pipeline centrally | Package a few steps to reuse inside jobs |
Disable or delete a workflow
| Disable | Delete | |
|---|---|---|
| How | Workflow's ⋯ → Disable workflow, gh workflow disable, or PUT …/workflows/{workflow_id}/disable | Remove the file from the branch (a commit), then optionally delete its runs |
| File | Stays in the repository, unchanged | Gone from that branch |
| Triggers | None fire until re-enabled; manual runs and re-runs are blocked too | None fire from that branch |
| History | Kept | Kept until the runs are deleted or expire; the workflow stays in the list while runs remain |
| Reversible | Yes — enable it again | Only by restoring the file |
| Use when | Pausing temporarily: an outage, a noisy schedule, an investigation | The 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.