9. Debugging runs
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
A workflow that fails on purpose
Section titled “A workflow that fails on purpose”.github/workflows/gha-09-debug.yml has four jobs, each one a situation you will meet:
# GitHub Actions course, lesson 9: reading a failing run, debug logging, re-runs, timeoutsname: "GHA 09: debugging"
on: workflow_dispatch: inputs: fail: description: Make the test step fail type: boolean default: true push: branches: [main] paths: ['.github/workflows/gha-09-debug.yml']
permissions: contents: read
jobs: noisy: runs-on: ubuntu-latest steps: - name: Groups, debug and notice messages run: | echo "::group::Environment of the step" echo "RUNNER_OS=$RUNNER_OS RUNNER_ARCH=$RUNNER_ARCH" echo "RUNNER_DEBUG='${RUNNER_DEBUG:-}'" echo "::endgroup::" echo "::debug::only visible when step debug logging is on" echo "::notice title=Lesson 9::a notice annotation" - name: Trace the commands with set -x run: | set -x version=$(date -u +%Y.%m.%d) test -n "$version" - name: Tests (fail on demand, attempt ${{ github.run_attempt }}) run: | if [ "${{ inputs.fail }}" = "true" ] && [ "${{ github.run_attempt }}" = "1" ]; then echo "::error title=Flaky test::failed on attempt ${{ github.run_attempt }}" exit 1 fi echo "tests passed on attempt ${{ github.run_attempt }}"
independent: runs-on: ubuntu-latest steps: - run: echo "this job doesn't depend on noisy"
allowed-to-fail: runs-on: ubuntu-latest continue-on-error: true steps: - run: exit 3
timeout: runs-on: ubuntu-latest timeout-minutes: 1 steps: - run: sleep 90The test step only fails on the first attempt: a flaky test, the most common reason to re-run.
Step 1: the summary, from the terminal
Section titled “Step 1: the summary, from the terminal”gh workflow run gha-09-debug.yml --field fail=truegh run view 34850662582The end of the output lists the annotations — every ::error, ::warning and ::notice, plus the errors the runner adds:
ANNOTATIONSX The job has exceeded the maximum execution time of 1m0stimeout: .github#1
X The operation was canceled.timeout: .github#5
X Process completed with exit code 1.noisy: .github#10
X failed on attempt 1noisy: .github#9
- a notice annotationnoisy: .github#14
X Process completed with exit code 3.allowed-to-fail: .github#5Read them before any log: here they already say which jobs failed and why — a timeout, a test, an expected failure. The title= of an annotation appears in the web page, not in this list.
Step 2: only the failed logs
Section titled “Step 2: only the failed logs”gh run view 34850662582 --log-failedThe full log of this attempt has 169 lines; --log-failed returned 15, only the failing steps of noisy and allowed-to-fail:
noisy Tests (fail on demand, attempt 1) ##[error]failed on attempt 1noisy Tests (fail on demand, attempt 1) ##[error]Process completed with exit code 1.allowed-to-fail Run exit 3 ##[error]Process completed with exit code 3.The timeout job isn’t there: its step was cancelled, not failed. When a job seems to vanish from --log-failed, look at its conclusion.
Workflow commands that structure a log
Section titled “Workflow commands that structure a log”In the normal log of the first step:
##[group]Environment of the stepRUNNER_OS=Linux RUNNER_ARCH=X64RUNNER_DEBUG=''##[endgroup]##[notice]a notice annotation::group::title…::endgroup::fold lines into a collapsible section in the web log.::notice,::warning,::errorcreate annotations (withtitle=,file=,line=— lesson 4).::debug::printed nothing: debug messages are hidden unless debug logging is on.
For shell scripts, set -x prints each command after expansion, the equivalent of stepping through:
++ date -u +%Y.%m.%d+ version=2026.09.14+ test -n 2026.09.14Debug logging
Section titled “Debug logging”Two switches, either as repository secrets or variables, or for one re-run with --debug:
| Setting | Adds |
|---|---|
ACTIONS_STEP_DEBUG = true |
##[debug] lines: condition evaluation, inputs, the script file executed, ::debug:: messages |
ACTIONS_RUNNER_DEBUG = true |
runner diagnostic logs in the log archive |
The third attempt of the run was started with:
gh run rerun 34850662582 --debugThe same step, now:
##[debug]Evaluating condition for step: 'Groups, debug and notice messages'##[debug]Evaluating: success()##[debug]Evaluating success:##[debug]=> true##[debug]Result: true##[debug]Starting: Groups, debug and notice messages...##[debug]/usr/bin/bash -e /home/runner/work/_temp/e3c00f12-b91a-4ef4-830d-2340170b7bc7.sh::group::Environment of the step##[group]Environment of the stepRUNNER_OS=Linux RUNNER_ARCH=X64RUNNER_DEBUG='1'::endgroup::##[endgroup]##[debug]only visible when step debug logging is on##[notice]a notice annotation- Every
if:shows how it was evaluated — the fastest way to understand a step that was skipped. RUNNER_DEBUGis1: a script can print more when it’s set.- The
noisyjob went from 70 to 190 log lines.
The log archive of that attempt, downloaded with gh api repos/spareilleux/learn/actions/runs/34850662582/attempts/3/logs > attempt3.zip, contains one folder per job with one file per step, and a runner-diagnostic-logs folder:
runner-diagnostic-logs/103998873521-noisy.zip 13448 Runner_20260914-134407-utc.log 73191 Worker_20260914-134409-utc.logThe Runner log is the agent that picks up jobs; the Worker log is the process that executes the steps. You rarely need them, except when a job fails before its first step.
Re-running
Section titled “Re-running”The documentation: “Re-runs use the privileges of the actor who initially triggered the workflow … The workflow will also use the same GITHUB_SHA (commit SHA) and GITHUB_REF”. A re-run tests the same commit: it can reveal a flaky test, not validate a fix you just pushed.
Attempt 2 was started with gh run rerun 34850662582 --failed:
noisy success attempt=2 13:42:13 → 13:42:16timeout cancelled attempt=2 13:42:13 → 13:43:42allowed-to-fail failure attempt=2 13:42:13 → 13:42:16independent success attempt=2 13:40:15 → 13:40:18noisypassed:github.run_attemptwas2, and even the step name changed (Tests (fail on demand, attempt 2)).- The three jobs that hadn’t succeeded ran again — the cancelled one included;
independentwasn’t re-run: its times are those of attempt 1, carried over. - Each attempt keeps its own logs (
attempts/1/logs,attempts/2/logs…).
Timeouts and conclusions
Section titled “Timeouts and conclusions”timeout-minutes defaults to 360 minutes per job. The timeout job was limited to 1 minute and ran sleep 90:
2026-09-14T13:44:10.0526092Z ##[group]Run sleep 902026-09-14T13:45:37.2574842Z ##[error]The operation was canceled.2026-09-14T13:45:37.3009029Z Terminate orphan process: pid (2052) (sleep)Cancelled after 87 seconds, not 60 — on every attempt, and in the exercise with sleep 300. Treat the limit as “cancelled some time after the limit”, and set it well below the time you’re ready to pay for.
The conclusions of the runs, which decide the red or grey icon:
| Run | Jobs | Run conclusion |
|---|---|---|
| push | noisy ✓, independent ✓, allowed-to-fail ✗ (continue-on-error), timeout cancelled |
cancelled |
| dispatch, attempt 1 | noisy ✗, timeout cancelled, others as above |
failure |
| dispatch, attempts 2 and 3 | noisy ✓, timeout cancelled |
cancelled |
A timeout makes the run cancelled, not failed — and a notification rule or a required check that only looks for failures misses it.
Key takeaways
Section titled “Key takeaways”- Start with
gh run view <id>(annotations), thengh run view <id> --log-failed; cancelled jobs aren’t in the failed logs. ::group::,::notice::andset -xmake logs readable;::debug::needs debug logging.gh run rerun --debugshows how everyif:was evaluated, and adds runner diagnostic logs.gh run rerun --failedre-runs every job that didn’t succeed, on the same commit.- A job timeout cancels the job (here 87 s for a 1-minute limit) and makes the run cancelled.
Exercises
Section titled “Exercises”- Move the timeout from the job to the step:
timeout-minutes: 1on asleep 300step, followed by a step withif: always(). What’s the step’s conclusion, and does the next step run?
Solution
Checked with gha-09-exercises.yml:
step-timeout | Run date -u +%T | 13:46:49step-timeout | Run date -u +%T | ##[error]The action 'Run date -u +%T' has timed out after 1 minutes.step-timeout | Run date -u +%T | 13:48:01The step failed (not cancelled) after 72 seconds, the always() step ran, and the job’s conclusion was failure. The job-level timeout of the same run cancelled its job after 87 seconds (13:46:49 → 13:48:16), with sleep 300: the delay doesn’t depend on the script.
- The only failing job of a run has
continue-on-error: true, and another jobneedsit. What’s the run’s conclusion, and what doesneeds.allowed-to-fail.resultcontain?
Solution
allowed-to-fail failureafter successrun conclusion successneeds.allowed-to-fail.result=successThe job is shown as failed, but the run succeeds — “Set to true to allow a workflow run to pass when this job fails” — and for the dependent job, the result is success: after ran without any if:. A dependent job can’t tell that the failure was tolerated.
- A job ran although you expected its
if:to skip it. Where can you see how GitHub evaluated the condition, without debug logging?
Solution
In the job’s system log, part of the log archive (gh api repos/<owner>/<repo>/actions/runs/<run-id>/attempts/<n>/logs), file <job>/system.txt. For noisy, on attempt 1, without debug:
Requested labels: ubuntu-latestJob defined at: spareilleux/learn/.github/workflows/gha-09-debug.yml@refs/heads/mainWaiting for a runner to pick up this job...Evaluating noisy.ifEvaluating: success()Result: trueA job without if: is evaluated as success(). For the if: of steps, re-run with --debug: the ##[debug]Evaluating condition for step lines appear in the step’s log.