Skip to content

Annotations

An annotation is a markdown note attached to a run and shown on its run page. Use one to surface what matters about a run without making someone read the logs: a build summary, a coverage table, lint findings, a link to a deployed preview.

Terminal window
spool annotate --style success --title "Build summary" \
"**3 packages** built, **71 tests** green. Image pushed to registry.example.com."

The run page in the console shows annotations above the steps, rendered as sanitized markdown (tables, links, lists, code) and sorted by severity with errors first.

A workflow writes the body, and a fork’s pull request can start a workflow, so the console treats the body as untrusted input. Script, event handlers and unknown URL schemes are stripped, and so are <form>, <button> and the style attribute: together those could paint a convincing sign-in over the page and post what gets typed elsewhere, with no script at all. The console also serves a Content-Security-Policy that refuses the same things, so a gap in one is covered by the other.

Formatting survives. A body generated from tool output keeps its markdown and inline HTML and loses anything interactive. Images from other hosts are refused too, because loading one would tell whoever wrote the annotation that you opened the run; inline a status badge as a data: URL.

spool annotate writes one annotation. The body comes from the positional argument, or from stdin if omitted:

Terminal window
spool annotate --style info "Plain note"
some-tool --report | spool annotate --style warn --title "Report"

Flags:

Flag Meaning
--style error | warn | info | success (default info)
--title Optional bold heading above the body
--name NAME A stable key. Annotating again with the same name replaces the previous note (or appends with --append) instead of adding a new one
--append With --name, concatenate onto the existing note rather than replacing

--name keeps one live annotation current through a long pipeline, such as a deploy note the deploy job overwrites as it progresses, where plain calls would stack four near-identical notes. Names starting with spool. are reserved for the engine, and spool annotate refuses them.

A run holds at most 200 annotations, named ones included; annotating again under a name the run already has updates that note and always works. A title or body is at most 64 KiB, and --append is refused once it would take the note past that.

Two notes appear on a run without anyone asking for them, both in the warn style.

agent lost appears when an agent running one of the run’s steps stops reporting for longer than its lease, and the step goes back on the queue for another agent. There is one per step; losing another agent on the same step replaces it. It says the lease lapsed, since from the server a dead agent and a silent one look the same. The ×N beside the step counts attempts; the note says why they happened. What the lost attempt printed is in the run’s raw log (spool logs <run-id> --follow).

This run finished before its fan-out children did appears on a run that succeeded while activities it submitted with spool activity submit were still going, because nothing collected them. It names the unfinished ones (the first five, then a count). Their results were never recorded, so a failure among them would otherwise go unnoticed.

spool annotate works in any step that runs on an agent: a steps: job, a graph shell or code step, and a polyglot script (a script: job, a workflow: script job, or spool submit --as=script).

It is a side-channel call: it talks to the server but records nothing in the run’s history. In a script it runs again on every replay pass, so a run that suspends N times posts the same note N+1 times. In any script that sleeps or waits, give the call a --name so each pass replaces the note.

For the same reason it works in a shell step, where the durable primitives (spool step, sleep, wait signal, activity) are refused: a shell step runs once and can’t suspend and replay, and an annotation doesn’t need to. Atomic and durable activities sets out which calls fall on which side. See the YAML reference for the job kinds and Polyglot scripts for the durable toolkit.

jobs:
build:
steps:
- name: compile
run: |
set -eu
start=$(date +%s)
go build ./...
spool annotate --style success --title "Build" \
"Compiled in $(( $(date +%s) - start ))s."
- name: coverage
run: |
set -eu
go test -cover ./... > coverage.txt
spool annotate --name coverage --style info --title "Coverage" \
"$(printf '```text\n%s\n```' "$(cat coverage.txt)")"