Job Summary
A GitHub Actions job summary is a markdown panel a job writes to its own run page by appending to the GITHUB_STEP_SUMMARY file. Rules, limits, and an example.
A GitHub Actions job summary is a markdown panel that a job publishes to its own run page. A step creates one by appending GitHub flavored markdown to the file whose path sits in the GITHUB_STEP_SUMMARY environment variable, and GitHub renders the collected markdown on the workflow run summary page (GitHub workflow commands reference, checked on 2026-08-13).
The panel exists so that a result can be read without opening logs. Test counts, a coverage delta, the list of packages a monorepo rebuilt, and the digest of an image a job pushed are all facts a reader wants in three seconds, and a log view turns each of them into a scroll and a text search.
Definition
A job summary is markdown produced by the steps of one job and rendered on the run summary page under that job.
The mechanism is a plain file. GitHub sets GITHUB_STEP_SUMMARY in the environment of every step, pointing at a path on the runner filesystem. Anything that can append to a file can write a summary: echo inside a bash run block, Add-Content in PowerShell, a Python script, a compiled binary, or a JavaScript action. Writing a summary takes no API call and no token, so the same step works on a GitHub-hosted runner and on a self-hosted runner.
Four rules govern that file, and GitHub documents each of them in the workflow commands reference linked above.
GITHUB_STEP_SUMMARYis unique for each step in a job, so two steps write to two different files.- Each step is restricted to a maximum of 1MiB. If a step adds more, the upload for that step fails and an error annotation is created. The job conclusion is unaffected.
- Isolation is enforced between steps, so malformed markdown from one step cannot break markdown rendering for the steps that follow it.
- When the job finishes, the summaries for all steps in the job are grouped into a single job summary and shown on the workflow run summary page.
Appending and overwriting are ordinary shell operations on that path. >> adds to what the current step has already written. > replaces it, clearing the content of the current step while leaving every other step alone. The PowerShell pair is Add-Content to append and Set-Content to replace.
Rendering follows GitHub flavored markdown, so headings, tables, task lists, fenced code blocks, links, and collapsible <details> blocks all work.
The neighboring output surfaces
A workflow has four places to put output, and telling them apart makes the choice easy.
| Surface | Where a reader finds it | How a step produces it |
|---|---|---|
| Step log | Log view for the job, one collapsible block per step | Anything written to stdout or stderr |
| Annotation | Top of the run page and inline in the pull request files view | The ::error and ::warning workflow commands, or an action that emits them |
| Artifact | Download list on the run page | actions/upload-artifact |
| Job summary | Run summary page, grouped under the job | Appending markdown to GITHUB_STEP_SUMMARY |
Logs carry the line by line record. Annotations point a reviewer at a file and a line. Artifacts move files off the runner for later download. A job summary carries a short rendered report that a person reads at a glance.
The toolkit builder
Actions written in JavaScript usually reach the same file through the core.summary builder in @actions/core rather than through shell redirection. The builder exposes addHeading, addTable, addList, addLink, addCodeBlock, addDetails, addImage, addQuote, addSeparator, and addRaw, and write() flushes the buffer into the file (actions/toolkit core README, checked on 2026-08-13). Passing { overwrite: true } to write() replaces the current step's content instead of adding to it.
Example
This workflow runs a unit test suite and then writes the counts into the run page as a markdown table, so the outcome is readable without opening the log.
name: test
on:
push:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
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 counts
if: always()
run: |
passed=$(jq '.numPassedTests' results.json)
failed=$(jq '.numFailedTests' results.json)
skipped=$(jq '.numPendingTests' results.json)
{
echo "## Unit tests"
echo ""
echo "| Result | Count |"
echo "| --- | --- |"
echo "| Passed | $passed |"
echo "| Failed | $failed |"
echo "| Skipped | $skipped |"
} >> "$GITHUB_STEP_SUMMARY"Three details in that last step decide whether the panel appears.
if: always() keeps the summary step running after the test step fails. A failing run is the run where the counts are worth reading, and without that condition the job stops before the panel is written.
The braces group the echo calls so the whole block is appended through one redirection. Redirecting on every line works as well and opens the file once per line.
The blank line after the heading is required. A markdown table has to start its own block, so a table glued directly under a heading line renders as plain text.
The run summary page then shows a heading and this table under the unit-tests job:
| Result | Count |
|---|---|
| Passed | 412 |
| Failed | 1 |
| Skipped | 3 |
The same panel from a JavaScript action, using the builder instead of shell redirection:
const core = require('@actions/core');
await core.summary
.addHeading('Unit tests', 2)
.addTable([
[
{ data: 'Result', header: true },
{ data: 'Count', header: true },
],
['Passed', String(passed)],
['Failed', String(failed)],
['Skipped', String(skipped)],
])
.write();Two habits keep summaries useful. Keep them at report size, because piping a full log tail into the file crosses the 1MiB step limit on a noisy run and loses the whole panel for that step. And remember that a matrix fans the panel out with the jobs: a matrix of twelve jobs writes twelve separate summaries, each grouped under its own job on the run summary page, so a per-job heading that names the matrix value keeps the run page readable.
Related Terms
- How to write a job summary in a GitHub Actions workflow: the shell and JavaScript forms, with the quoting and ordering rules that trip people up.
- Build a weekly GitHub Actions health report: rolling per-run summaries up into a scheduled report across a repository.
- Build duration percentile: the P75 and P90 statistics that a health report puts next to run counts.
- WarpBuild reports documentation: per-job duration and queue time percentiles collected across runs, with CSV export.
- WarpBuild quick start: pointing a first workflow at a managed runner fleet.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What is a GitHub Actions job summary?
A job summary is a markdown panel that a job publishes to the workflow run summary page. A step writes one by appending GitHub flavored markdown to the file path held in the GITHUB_STEP_SUMMARY environment variable, and GitHub renders the result under that job on the run page.
How large can a job summary be?
Each step is limited to 1MiB. If a step adds more than that, the upload for the step fails and an error annotation is created, while the job conclusion stays unchanged. Long output belongs in the logs or in an uploaded artifact rather than in the summary.
Do several steps in one job overwrite each other's summaries?
No. GITHUB_STEP_SUMMARY is unique for each step, so two steps write to two different files, and steps are isolated so malformed markdown in one cannot break rendering for the next. When the job finishes, the summaries for all its steps are grouped into a single job summary.
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.