Markdown appended to the file named by $GITHUB_STEP_SUMMARY appears on the run's page under the job's name, and ::error:: and its siblings (Runners and Commands) create annotations, listed on that page and, given file= and line=, shown beside the code in a pull request. The last job of ci.yml produces both from the JUnit 48,319 reports:
booknest-tests:
name: booknest/tests
needs: test
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v8
with: {pattern: junit-*, path: results}
- name: Summarize the test results
run: |
echo "| Job | Tests | Failures |" >> "$GITHUB_STEP_SUMMARY"
echo "|---|---|---|" >> "$GITHUB_STEP_SUMMARY"
for f in results/*/junit.xml; do
job=$(basename "$(dirname "$f")")
tests=$(grep -c '<testcase' "$f"); failed=$(grep -c '<failure' "$f" || true)
echo "| $job | $tests | $failed |" >> "$GITHUB_STEP_SUMMARY"
[ "$failed" = 0 ] || echo "::error title=$job::$failed failing tests in $job"
done
- if: needs.test.result != 'success'
run: exit 1pattern: junit-* puts each matching artifact in its own folder under results/. Run locally on the reports from 3.10.6 with GITHUB_STEP_SUMMARY=summary.md, the same loop printed four ::error lines and wrote a Markdown table with one row per report, each | 16 | 1 |: sixteen tests, one failure.

A summary may hold 1 MiB per step, and GitHub 29 keeps at most 10 annotations of each level per step, so aggregate. The signed-out page captured above lists the annotations but not the summary.