A CI job finishes. Somewhere in its 800 lines of Installing dependencies… and Downloading action… is the one fact anyone actually wanted: 142 passed, 1 failed, coverage 91.4%. To read it, a human opens the run, clicks the job, expands the step, and scrolls a green wall looking for the one red word. The log had the answer the whole time; it just refused to say it out loud.
$GITHUB_STEP_SUMMARY is GitHub Actions’ way of saying it out loud. It’s an environment variable pointing at a file, one per job, and anything you append to that file gets rendered as Markdown at the top of the run’s summary page — headings, tables, links, the works. No action to install, no API call. You already have echo.
This started as a note on it-journey.dev’s Initiation Rites: Agents in the SDLC quest — the part about giving an autonomous agent’s run a result a human can read at a glance instead of a log to excavate. This is the shell-side version: the two lines that put the answer on the summary page, plus the single character that silently deletes it.
Every block below is real captured output. $GITHUB_STEP_SUMMARY is just a file path, so you can reproduce the whole thing locally by pointing it at a temp file — which is exactly what I did (export GITHUB_STEP_SUMMARY="$(mktemp)") to capture these.
The two lines that beat the scroll
In a real job, GitHub sets GITHUB_STEP_SUMMARY for you. You append Markdown to it. That’s the entire mechanism:
- name: Summarize
run: |
echo "## Unit tests" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| suite | passed | failed |" >> "$GITHUB_STEP_SUMMARY"
echo "| ----- | ------ | ------ |" >> "$GITHUB_STEP_SUMMARY"
echo "| api | 142 | 0 |" >> "$GITHUB_STEP_SUMMARY"
echo "| web | 87 | 1 |" >> "$GITHUB_STEP_SUMMARY"
Locally, with GITHUB_STEP_SUMMARY pointed at a temp file, the file ends up holding exactly that Markdown:
$ cat "$GITHUB_STEP_SUMMARY"
## Unit tests
| suite | passed | failed |
| ----- | ------ | ------ |
| api | 142 | 0 |
| web | 87 | 1 |
On the run’s summary page, GitHub renders that as a real heading and a real table. You’ll know it worked when the summary page shows formatting instead of raw pipes — the ## Unit tests becomes a header, and the |-delimited rows become a bordered table. A reviewer reads the result without opening a single step.
Grouping the writes with one redirect is tidier than six >> lines and — this is the part that matters — makes the append atomic per step, so you can’t botch half of it:
{
echo "## Unit tests"
echo ""
echo "| suite | passed | failed |"
echo "| ----- | ------ | ------ |"
echo "| api | 142 | 0 |"
echo "| web | 87 | 1 |"
} >> "$GITHUB_STEP_SUMMARY"
It accumulates — that’s a feature and a trap
The file is not reset between steps of the same job. A later step’s append lands below the earlier one, so you can build the summary up piece by piece — tests here, coverage there:
$ cat "$GITHUB_STEP_SUMMARY"
## Unit tests
| suite | passed | failed |
| ----- | ------ | ------ |
| api | 142 | 0 |
| web | 87 | 1 |
## Coverage
Line coverage: **91.4%**
That’s the feature. Here’s the trap, and it’s the whole reason this hack has a “when it goes wrong” section: the append operator is >>. Type a single > — the muscle-memory slip everyone makes — and you don’t add to the summary, you replace it:
$ echo "## Oops (single gt)" > "$GITHUB_STEP_SUMMARY"
$ cat "$GITHUB_STEP_SUMMARY"
## Oops (single gt)
Everything the earlier steps wrote is gone. No error, no warning — the tests table and the coverage line just quietly evaporated, and your summary page now shows one lonely heading. The tell is subtle precisely because nothing fails. You’ll know you hit it when the summary page shows only the last thing written, not everything. The fix is a habit, not a flag: it’s always >>, every time, and the grouped { … } >> "$GITHUB_STEP_SUMMARY" form above gives the slip exactly one place to happen instead of six.
I opted the safe pattern into this site’s test harness (lh:run), so the version below is one that actually ran, in a locked-down no-network sandbox, on the build that published this page:
```bash lh:run #!/usr/bin/env bash set -euo pipefail
In a real job GitHub sets this to a per-job file. Simulate it:
export GITHUB_STEP_SUMMARY=”$(mktemp)”
One step appends a heading + a table built from real values.
pass=$(printf ‘a\nb\nc\n’ | grep -c .) # a real count: 3 { echo “## Smoke test” echo “” echo “| check | result |” echo “| —– | —— |” echo “| lines counted | $pass |” } » “$GITHUB_STEP_SUMMARY”
A later step appends more — it accumulates because we used ».
echo “finished at step 2” » “$GITHUB_STEP_SUMMARY”
Prove both writes survived (header + separator + one data row = 3 table lines).
test “$(grep -c ‘^|’ “$GITHUB_STEP_SUMMARY”)” -eq 3 grep -q ‘finished at step 2’ “$GITHUB_STEP_SUMMARY” echo “ok: both steps’ output is present”
The footgun: a single > truncates everything the earlier steps wrote.
echo “## whoops” > “$GITHUB_STEP_SUMMARY” test “$(grep -c ‘^|’ “$GITHUB_STEP_SUMMARY”)” -eq 0 echo “ok: one ‘>’ ate the whole summary — always use ‘»’”
## Build the row from something real
A summary that hardcodes `142 passed` is a lie waiting to happen. The point is to report what the job actually did, so build the values from real command output:
```console
$ count=$(ls -1 /etc | wc -l)
$ if grep -q '^root:' /etc/passwd; then status="✅ present"; else status="❌ missing"; fi
$ {
echo "## Environment check"
echo "| item | value |"
echo "| ---- | ----- |"
echo "| files in /etc | $count |"
echo "| root user | $status |"
} >> "$GITHUB_STEP_SUMMARY"
$ cat "$GITHUB_STEP_SUMMARY"
## Environment check
| item | value |
| ---- | ----- |
| files in /etc | 233 |
| root user | ✅ present |
233 and ✅ present came out of wc -l and grep, not out of my imagination. That’s the difference between a status badge and decoration.
The footgun with teeth: don’t pipe strangers into it
The summary is Markdown, and Markdown renders HTML. The moment you put untrusted text into it — a pull-request title, a branch name, an issue body from a stranger — you’ve handed control of your summary page to whoever wrote that text. Watch what a hostile PR title does to a table row:
$ PR_TITLE='Fix bug</td></tr></table><script>alert(1)</script> and add a | pipe'
$ echo "| PR | $PR_TITLE |" >> "$GITHUB_STEP_SUMMARY"
$ cat "$GITHUB_STEP_SUMMARY"
| PR | Fix bug</td></tr></table><script>alert(1)</script> and add a | pipe |
Two things just broke. The stray | splits the cell so your table’s columns don’t line up anymore, and the raw </table><script>… is HTML injected straight into a page a maintainer will open. GitHub sanitizes a lot of this on render, but “a lot” is not “all,” and depending on it is a bet you don’t need to make. The fix isn’t to escape harder — it’s to not route untrusted input through the summary at all. Report facts you computed (142, 91.4%, a commit SHA), and if you must echo a user string, drop it in a fenced code block where Markdown is inert:
- run: |
{
echo "### PR title"
echo '```'
cat <<'EOF'
$
EOF
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
When this goes wrong
- The summary page is empty. You wrote to the wrong place — a plain
echoprints to the log, not the summary. It only lands on the summary page when redirected to the file$GITHUB_STEP_SUMMARY. Also check you used>>and quoted the variable. - Only the last thing shows up. You used
>somewhere. It truncates. Find the single>and make it>>. This is the one that gets everyone. - Nothing renders as a table. GitHub Flavored Markdown needs the header separator row (
| --- | --- |) and a blank line before the table if it follows a paragraph. Miss either and it renders as literal pipes. - The summary from job A isn’t in job B. It’s
$GITHUB_STEP_SUMMARY, per job, not per workflow — each job gets its own file and its own summary section. There is no shared, workflow-global summary; if you need one, have each job upload its slice as an artifact and stitch them in a final job. $GITHUB_STEP_SUMMARY: unbound variable— you’re running underset -uoutside Actions (like the local reproduction above), where GitHub hasn’t set the variable. Point it at a temp file yourself:export GITHUB_STEP_SUMMARY="$(mktemp)".- The file grew to megabytes and got rejected. There’s a per-step size cap (~1 MiB). The summary is for the conclusion, not the full log — put the 800 lines in the log where they belong and the one table on the summary.
The reflex when someone asks “did CI pass?” is to send them a link and let them scroll. They shouldn’t have to. Two lines of echo to $GITHUB_STEP_SUMMARY put the answer on the front page of the run — just keep both > characters, and keep strangers out of the file.