How Do I Write a GitHub Actions Job Summary?
Append GitHub flavored markdown to the file named by GITHUB_STEP_SUMMARY and the run page renders it under that job. The table pattern and the limits.
Write a job summary by appending GitHub flavored markdown to the file whose path GitHub puts in the GITHUB_STEP_SUMMARY environment variable, and the workflow run page renders that markdown as a panel under the job. Any step that can append to a file can write one, so a run block with >>, a script, or a JavaScript action all work with no API call and no token.
Answer
GitHub sets GITHUB_STEP_SUMMARY in the environment of every step, pointing at a file on the runner. Whatever the steps of a job append there is grouped when the job finishes and shown on the run summary page under that job (GitHub workflow commands reference, checked on 2026-08-13). The file is per step, each step is capped at 1MiB, and a step that exceeds the cap loses its upload and gets an error annotation while the job conclusion stays unchanged.
The useful shape is a table of results and timings written after the tests run:
name: test
on:
pull_request:
jobs:
unit-tests:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Run unit tests
run: npm test -- --reporters=json --outputFile=results.json
- name: Publish test summary
if: always()
run: |
{
echo "## Unit tests"
echo ""
echo "| Suite | Passed | Failed | Skipped | Seconds |"
echo "| --- | --- | --- | --- | --- |"
jq -r '.testResults[]
| "| \(.name | split("/") | last) | \(.numPassingTests) | \(.numFailingTests) | \(.numPendingTests) | \(((.endTime - .startTime) / 1000) | floor) |"' \
results.json
} >> "$GITHUB_STEP_SUMMARY"That step puts this under unit-tests on the run page:
| Suite | Passed | Failed | Skipped | Seconds |
|---|---|---|---|---|
| billing.test.ts | 118 | 0 | 2 | 41 |
| runners.test.ts | 264 | 1 | 0 | 96 |
| webhooks.test.ts | 30 | 0 | 0 | 12 |
Four details decide whether the panel shows up at all.
if: always() keeps the writing step alive after the test step fails. Without it the step is skipped on exactly the runs where a reader wants the counts.
The braces group every echo into one redirection, so the block is appended once instead of opening the file per line. Quote the variable as "$GITHUB_STEP_SUMMARY", since the path lives under a workspace directory that can contain spaces.
The blank line after the heading is required. GitHub flavored markdown needs a table to begin its own block, and a table glued to the line above renders as plain text.
Windows runners take the same idea through PowerShell: Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "| Passed | 412 |", with Set-Content in place of > when a step wants to replace what it has written. One workflow can carry both forms across a matrix without a second reporting mechanism. The definition, the four documented file rules, and the core.summary builder from @actions/core are collected in job summary, defined.
Detail
What belongs in the summary and what belongs in the log
A summary is a report surface. It answers what happened and where to go next, and it holds no raw output.
| Content | Where it goes | Why |
|---|---|---|
| Pass, fail, and skip counts per suite | Summary | The number a reviewer wants before opening anything |
| Stack traces for failing tests | Log, linked from the summary | Long, wraps badly, and reads better with the surrounding step output |
| Coverage delta against the base branch | Summary | One line that decides whether the pull request is ready |
Full npm ci or compiler output | Log | Thousands of lines that would eat the 1MiB step budget |
| Published image digest and tag | Summary | The identifier the next person has to copy |
| Artifact and deployment URLs | Summary | Links are the point of a rendered panel |
| Per-test stdout | Log | Only read when a specific test is already suspect |
The 1MiB per-step cap turns this into a hard rule rather than a preference. A step that pipes a log tail into the file crosses the cap on the noisy run and loses the entire panel for that step, which is the run where the panel mattered. Keep the panel at report size and put the volume in the log or in an uploaded artifact.
Recording cache outcomes and job duration for later comparison
The same file is a good place to record two facts about the run itself, because they are what you compare when a workflow gets slower. This pattern writes the cache outcome and the wall-clock job duration as a row, and repeats the row as a single grep-able marker line in the log:
steps:
- name: Start timer
run: echo "JOB_START=$(date +%s)" >> "$GITHUB_ENV"
- uses: actions/checkout@v4
- name: Restore dependency cache
id: deps
uses: WarpBuilds/cache/restore@v1
with:
path: |
~/.npm
node_modules
key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
if: steps.deps.outputs.cache-hit != 'true'
run: npm ci
- run: npm test
- name: Record run facts
if: always()
run: |
duration=$(( $(date +%s) - JOB_START ))
cache="${{ steps.deps.outputs.cache-hit }}"
[ "$cache" = "true" ] || cache=false
{
echo "### Run facts"
echo ""
echo "| Fact | Value |"
echo "| --- | --- |"
echo "| Cache hit | $cache |"
echo "| Job seconds | $duration |"
echo "| Runner label | ${{ runner.os }}-${{ runner.arch }} |"
} >> "$GITHUB_STEP_SUMMARY"
echo "RUN_FACTS cache=$cache seconds=$duration label=${{ runner.os }}-${{ runner.arch }}"The panel is for a person opening one run. The marker line on stdout is for the next question, which is what the numbers look like over fifty runs, and gh run view <id> --log | grep RUN_FACTS reads it back. cache-hit is true only on an exact match against the primary key, so the row records exact matches and a restore-keys prefix restore shows as false; how to measure cache hit rate in GitHub Actions covers the counting rules and the break-even arithmetic behind them.
Two limits are worth knowing before this becomes the reporting plan for a repository. A summary is scoped to one run and nothing aggregates panels for you, and run logs are retained for 90 days by default (GitHub log retention settings, checked on 2026-08-13), which bounds how far back the marker lines go. Where the aggregate is the actual goal, the Jobs section of the WarpBuild Reports page already carries run count, success rate, duration P75 and P90, and queue time P75 and P90 per repository, workflow, and job name, with CSV export on every tab. Rolling per-run panels into one scheduled report is the subject of the weekly GitHub Actions health report guide.
What the instrumentation costs
Writing a summary makes no API call and consumes no cache operations, so the only cost is the runner seconds the step occupies. Price it directly: a warp-ubuntu-latest-x64-4x runner is $0.008 per minute (pricing page, checked on 2026-08-13), and a jq pass plus a handful of echo calls takes about 2 seconds.
- 2 seconds per run is 0.0333 minutes, or $0.000267 per run.
- At 1,000 runs a month that is 33.3 minutes, or $0.27.
- At 10,000 runs a month it is 333 minutes, or $2.67.
Pointing a first workflow at a managed runner is a one-line runs-on change, documented in the quick start.
Once the panel reports duration on every run, the numbers it collects feed the ordinary levers: runner size, cache warmth, and queue wait. The guide to speeding up GitHub Actions builds works through those in order, with the per-size rates to compare against.
Related Questions
Why is my job summary empty after the job fails?
The summary step usually never ran. A step with no condition is skipped once an earlier step in the job has failed, so the step that writes the panel needs if: always(). The second cause is size. Each step is limited to 1MiB, and a step that adds more loses its upload and gets an error annotation while the job conclusion stays unchanged (GitHub workflow commands reference, checked on 2026-08-13). The third is a matrix: twelve matrix jobs write twelve separate panels, so a missing one belongs to a job you have not scrolled to. Job summary, defined lists the file rules in full.
Should I put the full test log in the job summary?
No. Results and links go in the summary, raw output stays in the log. The panel is read in a few seconds on the run page, and the 1MiB per-step cap means a log tail piped into the file loses the whole panel on the noisy run. Write counts, timings, a coverage delta, the image digest, and the artifact links, then let the reader open the log for the trace. When the panel exists mostly to explain why a run got slower, the fixes it points at are in the guide to speeding up GitHub Actions builds.
How do I compare job summaries across runs?
A summary belongs to one run, so comparison needs a second surface. Either write a marker line to stdout beside the panel and read it back with gh run view <id> --log, which works for as long as the log retention window allows, or use an aggregate. The Jobs section of the WarpBuild Reports page aggregates run count, success rate, duration P75 and P90, and queue time P75 and P90 per repository, workflow, and job name, with a time-series chart and CSV export. For a scheduled digest built from run data, follow the weekly GitHub Actions health report guide, and for the cache side of the same row see how to measure cache hit rate in GitHub Actions.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.