Debugging and Speeding Up Workflows
Find out why a GitHub Actions run failed with gh run view, annotations and debug logs, write job summaries, and make runs faster and cheaper.
Sooner or later a run fails and the reason is not obvious, or the pipeline that took two minutes now takes twenty. This part walks through a real failure in the companion repository, from the red check on the pull request to the line that caused it, then shows the tools for harder cases: debug logging, annotations and job summaries. The second half is about making runs faster and cheaper.
Prerequisites
- The repository from Part 7, with CI running on pull requests.
A Pull Request That Fails
The change adds the number of commands to the footer of the page. It has two problems that are easy to miss in a review: a variable that is never used, and a test that still expects the old footer.
git show dc22f5a --format= -- src/render.js
diff --git a/src/render.js b/src/render.js
index 9fa02cf..c5acb0d 100644
--- a/src/render.js
+++ b/src/render.js
@@ -35,6 +35,8 @@ ${list.map((c) => `<dt><code>${escapeHtml(c.cmd)}</code></dt><dd>${escapeHtml(c.
)
.join('\n');
+ const count = commands.length;
+
return `<!doctype html>
<html lang="en">
<head>
@@ -45,7 +47,7 @@ ${list.map((c) => `<dt><code>${escapeHtml(c.cmd)}</code></dt><dd>${escapeHtml(c.
<body>
<h1>${escapeHtml(title)}</h1>
${sections}
-<footer>Version ${escapeHtml(version)}, commit ${escapeHtml(commit.slice(0, 7))}</footer>
+<footer>${commands.length} commands, version ${escapeHtml(version)}, commit ${escapeHtml(commit.slice(0, 7))}</footer>
</body>
</html>
`;
Opening the pull request ran CI, and the checks show the damage:
Twelve checks failed, and build was skipped. Start with the annotations: they are the short version of every error, and they appear on the pull request's Files changed tab next to the line:
That annotation comes from ESLint's output. actions/setup-node registers problem matchers, regular expressions that turn compiler and linter output into annotations with a file and line. The failed step's log has the full message:
--log-failed prints only the steps that failed, which is usually all you need. --attempt 1 picks the first attempt of the run; without it, gh shows the latest attempt.
And the required check did its job. all-checks failed instead of being skipped, so the ruleset from Part 7 blocks the merge:
Debug Logging
When the normal log is not enough, for example when a condition skips a step and you do not see why, re-run with debug logging. The runner then prints how it evaluated every expression and condition:
The debug log of that one job has 477 ##[debug] lines, so search it rather than read it. To turn debug logging on for every run instead of one re-run, create a repository variable (or secret) ACTIONS_STEP_DEBUG with the value true. ACTIONS_RUNNER_DEBUG=true adds the runner's own diagnostic logs. Turn both off again when you are done; debug logs are long, and in a public repository everyone can read them.
--failed re-runs only the failed jobs and the jobs that depend on them. A run can be re-run for up to 30 days and at most 50 times.
Annotations and Log Groups You Write Yourself
Any step can create annotations and structure its log by printing workflow commands:
echo "::error file=src/render.js,line=38::The footer is missing the version"
echo "::warning file=src/commands.json::The topic 'gh' has more than 10 commands"
echo "::notice::Built 13 commands"
echo "::group::Installed packages"
npm ls --depth=0
echo "::endgroup::"
::error and ::warning show up in the run summary and, with file and line, on the pull request diff. ::group:: folds a part of the log. An ::error line does not fail the step by itself; exit with a non-zero status for that.
Job Summaries
Every step can append Markdown to the file in $GITHUB_STEP_SUMMARY, and GitHub shows it on the run's summary page. The build job in ci.yml now writes a table of the built files (and tee also prints it to the log):
- name: Write a job summary
run: |
{
echo "### Site build"
echo ""
echo "| File | Bytes |"
echo "| --- | ---: |"
for f in dist/*; do
echo "| \`${f#dist/}\` | $(wc -c < "$f") |"
done
} | tee -a "$GITHUB_STEP_SUMMARY"
Use summaries for what a reviewer wants at a glance: test counts, coverage, bundle sizes, the URL of a preview deployment. Each step can write up to 1 MiB. Like logs, summaries mask known secret values; anything else you write stays there until you delete the run.
Fix It, and Let Concurrency Cancel the Old Run
The fix removes the unused variable and updates the test. It was pushed in two commits, 15 seconds apart. Because ci.yml has cancel-in-progress: true for pull requests, the second push canceled the run of the first:
The list shows the canceled run with an X like a failure; gh run view or the annotation tells you it was canceled. With green checks, gh pr merge 2 sent the pull request through the merge queue, and the deploy workflow published the new footer.
gh Tips for Runs
gh run watch <id> --exit-statusfollows a run and exits non-zero if it fails, handy after a push.gh run list --status failure --limit 10lists recent failures;--workflow,--branchand--eventnarrow it down.gh run view <id> --log-failedis the fastest way to the error;--job <id>and--attempt <n>pick one job or attempt.gh run download <id>fetches the artifacts, such as the JUnit reports from Part 4.- For jobs from a reusable workflow,
gh run view --logcannot name the steps and printsUNKNOWN STEPin the step column, so filter those logs by the job column or by text:
Make Runs Faster and Cheaper
Most slow pipelines have the same few causes. In order of how much they usually save:
- Do not run what is not needed. Cancel superseded runs with
concurrency(above), skip workflows withpathsfilters when only documentation changed, and run expensive suites, such as end-to-end tests, only on the merge queue or onmain. - Cache downloads. The setup actions' built-in caches cover most package managers;
actions/cachethe rest (Part 4). - Run in parallel. Split independent work into jobs, shard long test suites over a matrix (
--shard=1/4in many test runners), and use parallel steps for small independent commands (Part 7). - Pick the right runner. In Part 7, the same job took 30 seconds on Windows and 10 to 11 seconds on Linux and macOS. Run the full matrix where it matters and Linux elsewhere. For tiny jobs such as
all-checks, the companion repository usesubuntu-slim, a 1 vCPU runner that is a container rather than a VM, with a 15-minute job limit (generally available since January 2026):
- Set
timeout-minuteson every job. The default is 360 minutes (6 hours), lower only where the runner has its own limit, such as 15 minutes onubuntu-slim. In a private repository, one hung job can cost 360 billed minutes. - Fetch less.
actions/checkoutfetches one commit by default (fetch-depth: 1); only ask forfetch-depth: 0when a tool needs the history. - Look for slow steps. The step timings in the run page, or
gh api repos/{owner}/{repo}/actions/jobs/<id> --jq '.steps[]', show where the time goes. In Part 7, they showed that Windows spent its extra time in checkout andnpm ci, not in the tests.
When the hosted runners are not enough, larger runners (more cores, GPUs, static IP addresses) are billed even in public repositories, and self-hosted runners run on your own machines. Self-hosted runners must stay up to date: since 29 September 2026, runners older than v2.329.0 cannot register, and runners below a newer minimum version stop getting jobs.
Common Mistakes
- Reading the whole log. Start with the annotations and
--log-failed. - Debug logging left on. It slows reading and exposes more detail than you need, in public repositories to everyone.
continue-on-errorto get a run green. It hides the failure; fix it or make the step conditional.- No
timeout-minutes. One stuck job can cost up to 6 hours of runner time. - Optimizing before measuring. Check the step timings first; in the companion repository, the tests were the fastest part of every job.
Summary
- Annotations,
gh run view --log-failedand the job summary lead you to most failures quickly. gh run rerun --failed --debugshows how every condition was evaluated;ACTIONS_STEP_DEBUGturns debug logs on for all runs.- Workflow commands (
::error,::warning,::group::) and$GITHUB_STEP_SUMMARYmake your own steps easier to read. - Speed comes from skipping unneeded work, caching, parallel jobs and steps, the right runner, and timeouts.
In Part 9 you secure the pipeline: token permissions, untrusted input, unsafe triggers, and actions pinned to commit SHAs.
Keep Learning
Tags
Found an issue?