Observe CI commands
Hooks, structured events, and the opt-in GitHub Actions sink that folds each run into a log group.
01 — Problem
The job
A CI job runs commands nobody is watching. When one fails, the log should say which, with its output grouped where a reader can find it, and the job's own telemetry should record every run without anyone parsing output.
Cuprum reports a run through three channels at once here: structured
lifecycle events to an observe hook, start and exit
records through the standard logging module with
logging_hook, and the echoed output framed for GitHub
Actions by GitHubActionsSink.
02 — Before you start
Before you start
- Level
- Intermediate
- Needs
- Python 3.12 or later, which stands in for a test runner; the flags run also needs python3 on PATH
- Install
- Cuprum 0.2.0b2 from PyPI; see Getting started
- Tested
- 2026-10-19 against Cuprum 0.2.0b2 on Linux, Python 3.12; every run repeated eight times, identical apart from the random stop-commands token; the flags run with GITHUB_ACTIONS=true and python3 on PATH
- Uses
- sh.observe, ExecEvent, logging_hook, cuprum.sinks.GitHubActionsSink, RunOutputOptions(group=..., annotate_failure=...)
03 — Code
The code
import io
import logging
import sys
from cuprum import (
Program,
ProgramCatalogue,
RunOutputOptions,
logging_hook,
scoped,
sh,
)
from cuprum.sinks import GitHubActionsSink
PYTHON = Program(sys.executable)
CATALOGUE = ProgramCatalogue.from_programs(PYTHON, name="ci")
python = sh.make(PYTHON, catalogue=CATALOGUE)
events = []
def collect(event):
events.append((event.phase, event.exit_code))
logging.basicConfig(level=logging.INFO, format="%(name)s %(levelname)s %(message)s")
workflow_log = io.StringIO()
sink = GitHubActionsSink(workflow_log, title="Unit tests", force=True)
with (
scoped(catalogue=CATALOGUE),
sh.observe(collect),
logging_hook(logger=logging.getLogger("ci.commands")),
):
result = python("-c", "print('42 passed')").run_sync(
output=RunOutputOptions(echo=True, sink=sink),
)
print("result:", result.ok, repr(result.stdout))
print("phases:", [phase for phase, _ in events])
print("workflow log:")
print(workflow_log.getvalue(), end="")
04 — Output
Expected output
result: True '42 passed\n' phases: ['plan', 'start', 'stdout', 'exit', 'settled'] workflow log: ::group::Unit tests ::stop-commands::949162b00dd838c9 42 passed ::949162b00dd838c9:: ::endgroup::
The hook saw five ExecEvent phases: the plan, the
start, the output, the exit, and settled, which closes
every observed execution exactly once with a bounded
terminal_outcome (here exit_zero), even when
no child ever started. Each event also carries the program, the
argument vector, and an exec_id that ties one run's events
together, plus the process ID and timings where the phase has them: the
plan has no process yet, and a spawn failure settles without one.
The workflow log is what GitHub Actions reads: the output inside a
collapsible group titled Unit tests. The
::stop-commands:: pair around it, with a token that is
random on every run, stops the child's own output being read as
workflow commands. The cuprum.start and
cuprum.exit records went to the ci.commands
logger on stderr.
05 — Failure path
When it fails
The same step, failing. The sink adds one error annotation, titled after the group, which GitHub shows on the job summary.
import io
import sys
from cuprum import Program, ProgramCatalogue, RunOutputOptions, scoped, sh
from cuprum.sinks import GitHubActionsSink
PYTHON = Program(sys.executable)
CATALOGUE = ProgramCatalogue.from_programs(PYTHON, name="ci")
python = sh.make(PYTHON, catalogue=CATALOGUE)
FAIL = "import sys; print('1 failed, 41 passed'); sys.exit(1)"
workflow_log = io.StringIO()
sink = GitHubActionsSink(workflow_log, title="Unit tests", force=True)
with scoped(catalogue=CATALOGUE):
result = python("-c", FAIL).run_sync(
output=RunOutputOptions(echo=True, sink=sink),
)
print("result:", result.ok, result.exit_code)
print(workflow_log.getvalue(), end="")
result: False 1 ::group::Unit tests ::stop-commands::6bcf93e9497b517e 1 failed, 41 passed ::6bcf93e9497b517e:: ::endgroup:: ::error title=Unit tests::exit_nonzero
The run's result is unchanged by the framing: ok is
False and the exit code is 1, and capture still holds the
output. The sink activates by itself when GITHUB_ACTIONS is
true; force=True reproduces the framing
elsewhere, as here.
On the runner: two flags, no sink
A job that only wants the group and the annotation can skip the sink.
group=True and annotate_failure=True on
RunOutputOptions build a
GitHubActionsSink for the run, so one options object can be
shared by every step. The flags carry no force: they act
only when GITHUB_ACTIONS is true, and write to
the parent's stderr. This step calls python3 from
PATH, so the group's default title is the command line
itself.
from cuprum import Program, ProgramCatalogue, RunOutputOptions, scoped, sh
PYTHON = Program("python3")
CATALOGUE = ProgramCatalogue.from_programs(PYTHON, name="ci")
python = sh.make(PYTHON, catalogue=CATALOGUE)
# One options object for every step; inert off GitHub Actions.
CI_OUTPUT = RunOutputOptions(echo=True, group=True, annotate_failure=True)
FAIL = "import sys; print('1 failed, 41 passed'); sys.exit(1)"
with scoped(catalogue=CATALOGUE):
result = python("-c", FAIL).run_sync(output=CI_OUTPUT)
print("result:", result.ok, result.exit_code, repr(result.stdout))
::group::python3 -c import sys; print('1 failed, 41 passed'); sys.exit(1)
::stop-commands::977dbae16495e769
1 failed, 41 passed
::977dbae16495e769::
::endgroup::
::error title=ci%3A python3::exit_nonzero
result: False 1 '1 failed, 41 passed\n'
1 failed, 41 passed result: False 1 '1 failed, 41 passed\n'
The group is titled with the whole command line, arguments included, so
it appears in the step's log. The annotation, which GitHub also shows on
the job summary, carries only the run's bounded label,
ci: python3 (escaped as ci%3A in the workflow
command), and never an argument. Off the runner the flags do nothing: the
output is echoed as usual and the result is the same. An explicit
sink= always wins over the flags, so a step that needs its
own title passes a sink as the first two runs do.
06 — Why it is allowed
Why the policy permits it
The catalogue lists only this interpreter, and scoped(catalogue=...)
makes it the block's allowlist. The observe hook and the logging hook are
registrations local to the block: leaving it removes them, so a library
that runs commands elsewhere in the process is not observed by accident.
The logging hook records argv in full. Pass secrets through
a file rather than as arguments, or give the hook a
RedactionPolicy as
Keep secrets out of telemetry
shows, and never turn untrusted arguments into metric labels.