Troubleshoot GitHub Actions Workflow Runs: Logs, Matrix Re-runs and Artifacts (GH-200)
Troubleshooting a GitHub Actions workflow means reading what a run did and why: which event and filters started it (or failed to), what its step logs and run history show, how its matrix and YAML anchors expanded, and where its logs and artifacts can be found in the UI and through the API. This lesson covers the GitHub Actions (GH-200) skills for interpreting workflow behaviour and results and for accessing workflow artifacts and logs: trigger filters and skipped runs, the merge commit a pull request run builds, shell exit codes and debug logging, attempts and re-run rules, anchor and matrix expansion, selective matrix re-runs, and the REST and gh commands for logs and artifacts.
On this page10 sections
- How on: filters decide whether a run starts
- Skipped workflows, skipped jobs and pending required checks
- What a pull_request run actually builds
- Reading a failed run's logs
- Debug logging: ACTIONS_STEP_DEBUG vs ACTIONS_RUNNER_DEBUG
- Run history, attempts and re-run rules
- YAML anchors, aliases and merged mappings
- Reading a matrix: which jobs it creates
- Failures across matrix variants and selective re-runs
- Finding logs and artifacts in the UI, REST API and GitHub CLI
- Predict from an on: block and its filters which pushes, pull request events and merges start a run
- Explain why a skipped workflow leaves a required check pending while a job skipped by if reports success
- Diagnose failed runs from step logs, shell defaults, debug logging and the run's attempt history
- Apply re-run rules: same commit and ref, initial actor's privileges, 30-day window, and per-attempt logs
- Expand YAML anchors, aliases and matrix include/exclude entries to work out the jobs a run contains
- Choose between re-running all jobs, failed jobs or a single matrix job, in the UI, gh or the REST API
- Locate, download and delete logs and artifacts through the UI, download-artifact (including from another run), REST endpoints and the GitHub CLI
How on: filters decide whether a run starts
A workflow run starts only when an event listed under on: occurs and every filter on that event matches. When you read a trigger to predict or explain runs, check the event first, then the activity type, then each filter in turn: if any one of them fails, GitHub creates no run at all.
on:
push:
branches: [main]
paths: ['src/**']
pull_request:
branches: [main]
- branches and paths together: when an event has both a branch filter and a path filter, the run starts only if both are satisfied. A push to
mainthat touches onlydocs/fails the path filter; a push torelease/2.0that touchessrc/fails the branch filter. - paths compare the changed files: a path filter passes when at least one file changed by the push (or by the pull request) matches a pattern. A commit that changes
src/app.jsanddocs/guide.mdpassessrc/**. - branches-only filters ignore tags: if a
pushtrigger defines onlybranches(orbranches-ignore), pushing a tag does not start it. Tags are matched bytags/tags-ignore. - pull_request default activity types: with no
types:key,pull_requestruns foropened,synchronize(a new push to the head branch) andreopenedonly.closed,labeledand the rest need to be listed. Forpull_request,branchesfilters on the base branch. - merging is a push: merging a pull request pushes a commit to the base branch, so a
push: branches: [main]trigger starts a run for the merge, even thoughclosedis not a default pull request type.
Applied to the trigger above: pushing a new branch feature/login starts nothing (it is not main), opening the pull request into main and pushing to it start two pull_request runs, and merging starts a push run on main only if the pull request changed at least one file under src/, because the push trigger's path filter still applies to the merge commit. The run list in the Actions tab names the event of each run, which is the fastest way to confirm which trigger fired; inside a run, github.event_name and the event payload show the same thing.
Skipped workflows, skipped jobs and pending required checks
A workflow that is filtered out or skipped by a commit message creates no run, so any status check it would have produced never reports, and a required check stays Pending and blocks the merge. A job that is skipped by its if: condition is different: the run exists and the skipped job reports Success to required status checks.
| What is skipped | Is there a run? | Required check shows |
|---|---|---|
Whole workflow, by a branches/paths filter | No | Pending (blocks merging) |
| Whole workflow, by a skip instruction in the commit message | No | Pending (blocks merging) |
One job, by an if: that evaluates to false | Yes | Success |
Skip instructions. Workflows triggered by push or pull_request do not start when the commit message contains [skip ci], [ci skip], [no ci], [skip actions] or [actions skip], or ends with a skip-checks: true trailer (after two empty lines). For a pull request, the head commit's message counts. They apply only to push and pull_request: workflows on pull_request_target, workflow_dispatch and other events still run. Skip instructions are the only per-commit switch: disabling a workflow in the Actions tab is a separate, repository-wide setting that stops all of that workflow's runs until it is enabled again.
Path-scoped required checks. If a required check comes from a workflow with a paths filter, pull requests that change no matching files wait forever. Swapping paths for paths-ignore has the same problem, and branch protection cannot require a check only for certain paths. The reliable pattern is to let the workflow always run and skip the expensive job instead, for example with an if: on the output of an earlier job that detects which files changed — the skipped job then reports Success and the pull request can merge.
What a pull_request run actually builds
A pull_request run does not build the head branch's latest commit: GITHUB_REF is refs/pull/<number>/merge and GITHUB_SHA is the last merge commit on that ref — the head branch merged into the current base branch. actions/checkout with default inputs checks out that merge commit, so git rev-parse HEAD in the job prints a SHA that matches no commit on the head branch.
| Event | GITHUB_REF | GITHUB_SHA |
|---|---|---|
push | The branch or tag pushed | The pushed commit |
pull_request | refs/pull/<number>/merge | Last merge commit of head into base |
pull_request_target | The pull request's base branch | Last commit on the base branch |
This explains a common failure: a branch passes locally, but its pull request run fails in a file the pull request never touched, because commits landed on main after the branch was created and the merge result no longer compiles. To reproduce it, merge the current base branch into the branch locally (or check out the merge ref) and build that — building the branch tip or main alone shows only half of the combination. pull_request_target runs in the context of the base branch, which is why it is used for trusted automation such as labelling, not for building contributor code.
Reading a failed run's logs
To diagnose a failed run, open the run, select the failed job and read its step logs: failed steps expand automatically, annotations at the top of the run summary point to the errors, and each step shows its command and output. Two log-page behaviours often confuse people: the Search logs box only searches steps that are currently expanded (expand a collapsed passing step, or download the log archive, to search it), and values registered as secrets are masked as ***.
Why a step can pass when a command failed. A run step's exit code decides whether it passes, and the shell decides what that exit code is. On Linux and macOS:
| Step setting | Command GitHub runs | A failure early in a pipeline |
|---|---|---|
No shell: (default) | bash -e {0} | Hidden: the pipeline returns the last command's status |
shell: bash | bash --noprofile --norc -eo pipefail {0} | Fails the step (pipefail) |
shell: sh | sh -e {0} | Hidden: no pipefail |
So when a step pipes a command into another one, such as pytest | tee report.log, the default shell reports only the last command's status: the step passes even when the first command fails. Setting shell: bash explicitly adds pipefail, so any failing command in the pipeline fails the step while the pipeline itself is unchanged. Step settings that decide whether a step starts or whether its failure stops the job do not change the exit code the shell produces. On Windows runners the default shell is PowerShell (pwsh).
Other clues in a failed run: a job that hit timeout-minutes (default 360 minutes per job) is reported as cancelled with a message that it has exceeded the maximum execution time — that message, not the missing output, is the reliable clue; a job marked cancelled in a matrix may have been stopped by fail-fast (see below); and the run's history in the Actions tab shows whether the same workflow passed on earlier commits, which narrows the change that broke it.
Debug logging: ACTIONS_STEP_DEBUG vs ACTIONS_RUNNER_DEBUG
GitHub Actions has two debug switches, and they produce different output: step debug logging adds debug lines to the step logs, while runner diagnostic logging adds the runner's own log files to the log archive.
ACTIONS_STEP_DEBUG = true | ACTIONS_RUNNER_DEBUG = true | |
|---|---|---|
| What you get | Debug lines in each step's log, including actions' core.debug() and ::debug:: output | Runner diagnostic logs (Runner_* and Worker_* files) |
| Where it appears | In the step logs on the run page | In the runner-diagnostic-logs folder of the downloaded log archive |
| Typical use | An action or step needs more detail | The runner or job setup itself misbehaves |
Both can be set as a repository (or organization) secret or variable named exactly as shown, with no workflow edit, and then apply to every later run. If a secret and a variable with the same name both exist, the secret takes precedence, so check both lists when debug output does not appear as expected. RUNNER_DEBUG is different: the runner sets it to 1 when debug logging is on, so scripts can check it; setting it yourself does not enable debug output.
For a one-off investigation, use Re-run jobs → Enable debug logging on the run page (or gh run rerun <run-id> --debug, or enable_debug_logging in the REST re-run requests). That turns on both step debug and runner diagnostic logging for that re-run only, without commits or settings that affect other runs. It cannot add debug output to an attempt that has already finished.
Run history, attempts and re-run rules
Re-running a workflow does not create a new run: it adds an attempt to the same run. github.run_id and github.run_number stay the same and github.run_attempt increments. In the web interface, the Latest dropdown next to the run name lists every attempt, so you open an earlier attempt's logs from there — the run list shows one entry per run, not per attempt.
A re-run replays the original run. It uses the same GITHUB_SHA and GITHUB_REF as the original event, so a fix committed after the run started is not picked up — even a fix to the workflow file itself. To test a fix you need a new run: push a new commit that triggers the workflow, or start it with workflow_dispatch (Run workflow). Remember that a fix commit carrying a skip instruction starts no push run.
Who and when. Re-running needs write access to the repository and is possible for up to 30 days after the initial run, with at most 50 re-runs of a run. In a re-run, github.actor remains the user who triggered the initial run and the attempt uses that user's privileges, while github.triggering_actor is the user who started this attempt.
Logs per attempt. Each attempt keeps its own logs. After Re-run failed jobs, only the re-run jobs actually ran in the new attempt, so the latest attempt's logs are not a reliable record of how every job ran the first time. To be sure you have every job's log as it originally ran, choose attempt 1 in the Latest dropdown and download its archive, or call the attempt endpoint:
GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}/logs
Listing run history via REST. To fetch the runs of one workflow, call GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs, where workflow_id can be the workflow's numeric ID or its file name (for example ci.yml), and filter on the server with query parameters such as branch, event and status. The repository-wide GET /repos/{owner}/{repo}/actions/runs lists runs of every workflow and has no workflow filter. GET /actions/runs/{run_id}/jobs returns each job's metadata — status, conclusion, steps, IDs — but not log text.
YAML anchors, aliases and merged mappings
A YAML anchor (&name) labels a node and an alias (*name) inserts a full copy of it elsewhere in the file. GitHub Actions workflow files support anchors and aliases, so before reasoning about what a workflow does, expand each alias into the content it copies.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: npm run lint
unit: &suite
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- &clone
uses: actions/checkout@v5
with:
submodules: true
- run: npm test
integration: *suite
- An aliased step is an exact copy, including its
with:inputs:- *clonein another job also checks out submodules. It is still a separate step on that job's own runner — aliases copy configuration, not files or results. - An aliased job copies the whole mapping, including
needs,ifandtimeout-minutes. Above,integrationgets the sameneedsasunit, so it has the same dependency and is skipped in the same circumstances. Note that an alias copies the job's body, not its key: the copy keeps its own job name.
Merged mappings. In YAML generally, a merge key (<<: *name) copies an anchored mapping's keys into another mapping and lets keys written locally override the copied ones. The official skill names merged mappings, but GitHub's workflow documentation describes anchors and aliases only and does not document merge keys, so do not rely on << in a workflow file without testing it. When reading any configuration that uses one, expand it the YAML way: start from the anchored mapping, then apply the locally written keys on top.
Reading a matrix: which jobs it creates
A matrix creates one job for every combination of its variables, then applies exclude, then applies include. To work out the jobs in a run, follow that order exactly:
- Cross product: two
osvalues × twonodevalues = four combinations. - exclude: removes every combination that matches the entry. A partial match counts, so
- os: windows-latestremoves all Windows combinations. - include (processed after exclude): each entry's key–value pairs are added to every combination they can join without overwriting an original matrix value. Values that an earlier include added can be overwritten by a later include. An entry that cannot join any combination becomes a new combination of its own.
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python: ['3.12', '3.13']
include:
- python: '3.13'
coverage: full
- os: windows-latest
coverage: none
- os: macos-latest
python: '3.13'
Expanded: the first entry adds coverage: full to both Python 3.13 combinations. The second joins both Windows combinations and, on Windows/3.13, overwrites the added full with none. The third would have to overwrite an original os value in every combination, so it becomes a new, fifth job (macOS, Python 3.13, no coverage). Two general consequences follow: because include runs after exclude, an include can add back a single combination that an exclude removed; and an include whose original-matrix values all match existing combinations only adds keys to them and creates no new job.
Correlating names to axes. By default a matrix job's name is the job name followed by its matrix values in parentheses, such as test (ubuntu-latest, 22), in the order the variables are declared. Values that an include adds to a combination appear in that name too. Read the job list against the axes to see which values each job used.
Failures across matrix variants and selective re-runs
When matrix variants fail, look for the axis the failures share: if every Windows job fails whatever the Node.js version, the cause is specific to Windows (paths, line endings, shell syntax); if every Node.js 24 job fails across operating systems, look at that runtime. Only jobs that actually ran to a failure tell you something — cancelled jobs have unknown results.
fail-fast defaults to true: when one matrix job fails, GitHub cancels the matrix's in-progress and queued jobs. A run with one red variant and several cancelled ones therefore has exactly one known failure. Set fail-fast: false to let every variant report. max-parallel only limits how many variants run at once, and a concurrency group cancels across runs, not between variants of one run.
Re-run options. Each matrix variant is its own job with its own job ID, so you can retry exactly the variants you need:
| Option | What runs again | UI / CLI / REST |
|---|---|---|
| Re-run all jobs | Every job in the run | Run page; gh run rerun <run-id>; POST /actions/runs/{run_id}/rerun |
| Re-run failed jobs | All failed jobs plus the jobs that depend on them; passing variants are not re-run | Run page; gh run rerun <run-id> --failed; POST /actions/runs/{run_id}/rerun-failed-jobs |
| Re-run one job | That job (one matrix variant) plus the jobs that depend on it | The re-run icon on the job; gh run rerun --job <job-id>; POST /actions/jobs/{job_id}/rerun |
Dependents are included because a job that needed the failed one was skipped: re-running a failed test (windows-latest, 22) also runs the report job that needs test. "Re-run failed jobs" always retries every failed job, so to retry only some of the failures, list the run's jobs with GET /actions/runs/{run_id}/jobs, take each chosen variant's job ID, and re-run those jobs individually. The --job flag and the job endpoint take the numeric job ID, not the job's name. All of these respect the same rules as any re-run: same commit, 30-day window, write access.
Finding logs and artifacts in the UI, REST API and GitHub CLI
Every run's logs and artifacts are reachable from its run page, and the same data is available through the REST API and gh. In the UI, open the Actions tab, choose the workflow in the sidebar, then the run: the job list and step logs are on the run page, the log view's menu offers Download log archive, and uploaded files are listed in the Artifacts section of the run summary, where clicking an artifact's name downloads it as a zip.
| Need | REST request | Returns |
|---|---|---|
| All logs of a run (latest attempt) | GET /actions/runs/{run_id}/logs | 302 redirect to a zip archive |
| All logs of one attempt | GET /actions/runs/{run_id}/attempts/{n}/logs | 302 redirect to a zip archive |
| One job's log | GET /actions/jobs/{job_id}/logs | 302 redirect to plain text |
| Delete a run's logs | DELETE /actions/runs/{run_id}/logs | 204, run kept |
| A run's artifacts | GET /actions/runs/{run_id}/artifacts?name=… | Artifact list with IDs |
| Artifacts across the repository | GET /actions/artifacts?name=… | Same-named artifacts from every run |
| Download an artifact | GET /actions/artifacts/{artifact_id}/zip | 302 redirect to the zip |
| Delete an artifact | DELETE /actions/artifacts/{artifact_id} | 204, run and logs kept |
All paths start with /repos/{owner}/{repo}. The download endpoints answer with a 302 redirect whose URL expires after one minute, so a script must follow it straight away. Artifacts are addressed by ID: there is no download-by-name endpoint, zip is the only archive format, and the repository-wide name filter can return artifacts from other runs, so to fetch a named artifact from a specific run, filter that run's artifact list by name and use the ID it returns. An artifact's metadata also shows expired and expires_at. Annotations from the checks API and the jobs list are not log text.
GitHub CLI. gh run view <run-id> --log prints every step's log, and --log-failed prints only the failed steps. gh run download <run-id> downloads all of a run's artifacts, and -n <name> downloads only the named one. Reading logs is always gh run view; gh run list and gh run rerun list and re-run runs.
Downloading in a later job. Within the same run, a later job gets an artifact with actions/download-artifact; the job must needs the uploading job so the artifact exists when it starts.
- id: get
uses: actions/download-artifact@v8
with:
name: web-bundle
path: out/
namedownloads one artifact, extracted directly intopath(default: the workspace). With noname, every artifact of the run is downloaded, each into its own directory named after the artifact.patternselects artifacts by a glob on their names, andmerge-multiple: trueextracts all matching artifacts into the same directory instead of one directory each. Neither changes file contents or permissions.- The
download-pathoutput gives the directory the files landed in, for later steps. It belongs to download-artifact; upload-artifact has no such output. - Downloading from a different run needs
run-idplus agithub-tokenwith read access to Actions (andrepositorywhen that run is in another repository). The token is what switches the source: withoutgithub-token,run-idis ignored and the action downloads from the current run, so a missing token shows up as an artifact not found. - When the token is
GITHUB_TOKENand the workflow or job sets apermissionsblock, every scope it does not list becomes no access, so the block must includeactions: reador the other run's artifacts cannot be read. A typical case is a deploy workflow triggered byworkflow_runthat fetches the build run's artifact withrun-id: ${{ github.event.workflow_run.id }}.
on:
workflow_run:
workflows: [Build]
types: [completed]
permissions:
actions: read
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: web-bundle
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
Inside the uploading job. actions/upload-artifact returns artifact-id, artifact-url and artifact-digest outputs, so a later step can link the artifact in the job summary with steps.<id>.outputs.artifact-url — no API call and no hand-built URL.
Managing artifacts. Deleting needs write access. Deleting one artifact keeps the run and its logs; deleting a run deletes its logs and artifacts too, and there is no single call that deletes all of one run's artifacts. Retention settings decide when artifacts and logs expire and apply to new artifacts, not ones already stored (retention configuration itself is covered with workflow authoring). Uploaded artifacts are zipped, and zipping does not keep file permissions — downloaded directories get 755 and files 644, so a compiled binary loses its execute bit. To keep exact modes, pack the files into a tar archive, upload that single file with archive: false (supported from upload-artifact@v7), and extract it after download.
Tip. Expect scenario items that show a short YAML snippet or describe a run page and ask what happened or what to do next: which pushes, pull request events and merges start a run, why a required check is pending, which commit a pull request run built, why a step passed although a command failed, and which debug setting produces the missing output. Re-run items contrast re-run all jobs, failed jobs and one job by ID, and what a re-run keeps (commit, ref, actor), often with a constraint such as no commits, no repository-wide settings, or retrying one matrix variant but not another. Matrix and anchor items ask you to expand the configuration and count or name the resulting jobs. API and CLI items test exact endpoints: run vs attempt vs job logs, artifacts by run, by name and by ID, the one-minute redirect, gh run view --log-failed or gh run download -n, and what download-artifact needs (run-id, github-token, actions: read) to fetch another run's artifact.
- A run starts only when the event, its activity type and every filter match; branches plus paths must both pass, and branches-only push filters ignore tags.
- pull_request defaults to opened, synchronize and reopened; merging a pull request is a push to the base branch.
- A workflow skipped by a filter or [skip ci] creates no run, so its required check stays Pending; a job skipped by if reports Success.
- pull_request runs build refs/pull/N/merge, a merge of the head into the current base, not the branch tip.
- The default Linux shell is bash -e without pipefail; shell: bash adds pipefail. Log search covers expanded steps only.
- ACTIONS_STEP_DEBUG adds step debug lines, ACTIONS_RUNNER_DEBUG adds runner diagnostic logs; a secret beats a variable of the same name.
- A re-run is a new attempt of the same run on the same commit and ref, with the initial actor's privileges; a fix needs a new run.
- Matrix: cross product, then exclude (partial match), then include; fail-fast cancels the rest, so cancelled variants are unknown.
- Re-run failed jobs retries every failed job plus dependents; re-run one variant by its job ID to retry only that job and its dependents.
- Log and artifact downloads are 302 redirects valid for one minute; artifacts are found and deleted by ID, and zipped artifacts lose file modes.
- download-artifact reads another run only when github-token is set (run-id alone is ignored); with GITHUB_TOKEN, an explicit permissions block must also grant actions: read.
Frequently asked questions
Why does my GitHub Actions required check stay pending on some pull requests?
The check stays pending when the workflow that produces it never ran — for example because a paths or branches filter excluded the pull request or a skip instruction such as [skip ci] was in the head commit's message. A skipped workflow creates no run, so no status is reported. Let the workflow always run and skip the job with an if condition instead, because a job skipped by a conditional reports Success.
Why does a GitHub Actions pull_request run use a commit that is not on my branch?
A pull_request run checks out refs/pull/<number>/merge, and GITHUB_SHA is the merge commit of the head branch into the current base branch. The run therefore tests the result of merging, which can fail on files the pull request did not change if the base branch has moved. Merge the base branch into your branch locally to reproduce it.
What is the difference between ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG?
ACTIONS_STEP_DEBUG set to true adds debug lines, including actions' core.debug output, to the step logs. ACTIONS_RUNNER_DEBUG set to true adds the runner's diagnostic log files to the runner-diagnostic-logs folder of the log archive. Both can be a secret or a variable, and a secret wins if both exist. Re-run with debug logging enables both for one re-run only.
Does re-running a GitHub Actions workflow use the latest commit?
No. A re-run uses the same GITHUB_SHA and GITHUB_REF as the original event, even if newer commits or a workflow fix have landed since. It adds an attempt to the same run, keeps the initial actor's privileges, and is available for 30 days after the initial run. To test a fix, start a new run with a new push or a manual workflow_dispatch.
How do I re-run just one matrix job in GitHub Actions?
Each matrix variant is a separate job with its own ID. Use the re-run icon on that job in the run page, gh run rerun --job <job-id>, or POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun. That re-runs the chosen variant plus any jobs that depend on it, while Re-run failed jobs would retry every failed variant.
How does GitHub Actions apply include and exclude in a matrix?
GitHub builds the cross product of the matrix variables, removes any combination that matches an exclude entry (a partial match is enough), and then processes include. Each include entry is added to every combination it can join without overwriting an original value; if it can join none, it becomes a new combination. Values added by an earlier include can be overwritten by a later one.
How do I download a GitHub Actions artifact with the REST API?
Find the artifact's ID first, for example with GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts?name=<name>, then call GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip. The response is a 302 redirect whose URL expires after one minute. There is no download-by-name endpoint and zip is the only archive format; with the GitHub CLI, gh run download <run-id> -n <name> does the same.
Why does a downloaded GitHub Actions artifact lose its executable permission?
Artifacts are uploaded as zip archives, which do not keep file permissions, so downloaded directories get mode 755 and files 644. To keep exact modes, pack the files into a tar archive in the build job, upload that single file with archive: false on actions/upload-artifact, and extract the tar after downloading it.
Source
This lesson covers the "Consume and troubleshoot workflows" domain of the official GH-200 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.