actbreak is a local breakpoint debugger for GitHub Actions: pause a workflow mid-step and get a real shell inside the still-running job container.
Built on act, which runs GitHub Actions workflows
locally but has no way to pause mid-run on its own. actbreak injects the breakpoint,
waits for the job container to reach it, execs you in, and resumes the run when you're
done.
Zero runtime dependencies. Python 3.9+, stdlib only.
Early / v0.3.0. CHANGELOG.md lists
what changed. The injection, selection and session logic is unit tested
against fakes. One CI job also runs the commands themselves against real
act and Docker. It parks a job with run --no-attach, checks list, and
resumes it until its container is gone, once for a plain job and once for
a job with its own name:. It runs a failing job with --break-on-failure --no-attach, execs into the post-mortem container and cleans it. It also
runs one leg of a two-leg matrix with --matrix. Attaching a shell, steps
and init-vscode are covered by the unit tests only.
pipx install git+https://github.com/munzzyy/actbreak
Or from a clone, since it's stdlib-only:
git clone https://github.com/munzzyy/actbreak
cd actbreak
pip install -e .
Don't install actbreak from PyPI yet. The listing there is stuck at 0.1.0,
which predates actbreak list, actbreak steps, init-vscode, and shell
completions, and it still carries the old Prosperity license.
The 0.1.1 and 0.2.0 uploads failed, and the next one to go up will be 0.3.0,
so install from git until the PyPI page says 0.3.0.
Requires act on PATH, and one of Docker or Podman.
actbreak run <workflow.yml> --break-before <step>
actbreak run <workflow.yml> --break-after <step>
actbreak run <workflow.yml> --break-on-failure
actbreak steps <workflow.yml>
actbreak resume [SESSION]
actbreak clean [SESSION]
actbreak list
actbreak init-vscode
<workflow.yml> is either a path to a workflow file, or a bare name looked up
under .github/workflows/.
A step selector is either a step's name: value, or <job>:<index> to select
by zero-based position (use this for steps with no name:).
actbreak steps ci.yml
Prints every selector the workflow offers, selector first so you can paste one
straight into --break-before or --break-after:
build:
build:0 Checkout
build:1 Install deps
build:2 Run tests
lint:
lint:0 Checkout
lint:1 (unnamed)
--job JOB narrows it to one job. Steps with no name: show as (unnamed);
those are the ones you have to select by position.
| Flag | Meaning |
|---|---|
--break-before STEP |
pause immediately before STEP runs (repeatable) |
--break-after STEP |
pause immediately after STEP runs, whether it passed or failed (repeatable) |
--break-on-failure |
if act exits nonzero, attach to the last job container for post-mortem |
--job JOB |
disambiguate a multi-job workflow |
--runtime {docker,podman,auto} |
container runtime to use (default: auto-detect) |
--no-attach |
don't exec a shell automatically; print the attach command and hold |
--shell SHELL |
shell to attach with, e.g. zsh or 'bash -l' (default: try sh, then bash) |
--matrix KEY:VALUE |
run only the matrix leg where KEY is VALUE, passed to act's own --matrix (repeatable, once per matrix key) |
--timeout SECONDS |
how long to wait for each breakpoint before stopping act and removing its container (default: 1800; 0 means no deadline, wait as long as act runs) |
--act-arg ARG |
extra argument passed through to act (repeatable) |
-v, --verbose |
print the injection/act commands being run |
--break-before and --break-after are both repeatable and can be mixed, so
one run can step through several points instead of you re-running it fresh
for each one:
actbreak run ci.yml --break-before "Install deps" --break-after "Build"
They're hit in the order the job actually reaches them, which is by their
position in the file, not the order you passed them on the command line.
Attaching and exiting the shell (or running actbreak resume on a
--no-attach session) moves you to the next one; once you resume past the
last one the job runs to completion like normal. All of a run's breakpoints
have to resolve to the same job, since actbreak run only ever debugs one.
actbreak list
Shows the debug sessions a run --no-attach (or a resume/clean that
couldn't finish) left parked, one per line, with each one's live container
status read from docker ps / podman ps:
actbreak: 3 parked debug sessions:
act-CI-build [running] -- job 'build', step 'Run tests' (before), held for 5m -- /repo/.github/workflows/ci.yml
act-CI-test [gone] -- job 'test', step 'Build' (after), held for 3h -- /repo/.github/workflows/ci.yml
act-CI-lint [running] -- job 'lint', post-mortem after act exited 1, held for 2m -- /repo/.github/workflows/ci.yml
running is still held and attachable; stopped and gone are orphans to
clear with actbreak clean. The held for age is how long ago the session
was parked, so you can spot the one that's been sitting for hours instead of
minutes. With nothing parked it just says so.
A --break-on-failure --no-attach run that fails parks its post-mortem
container the same way. act has already exited by then and there is no hold
to drop, so resume leaves it alone and clean removes it.
actbreak clean removes every parked container and its temp files. If a
removal fails, it keeps that session, says so, and exits 1, so the next
clean can try again. A session whose container is already gone is just
dropped.
With several sessions parked, resume and clean act on all of them by
default. Name one to act on just that session: its container name as list
prints it, or any prefix of it that only one session has.
actbreak resume act-CI-test
actbreak clean act-CI-b
A prefix that matches more than one session, or none, is an error that lists
what is parked. clean with a name skips its sweep for untracked
containers, since that sweep would take the other parked sessions too.
While a job is parked, actbreak run won't start that job again. The new
run would stop at the old hold on its first check instead of its own, and
dropping either hold would drop both. The error names the container in the
way so you can resume or clean it first. A container that still holds a
breakpoint but has no recorded session blocks the run the same way.
actbreak resume drops the hold and then waits for the job to finish, so it
can reap the container instead of leaving it behind. That wait is the rest
of your workflow, so it can sit there for a while. Ctrl-C stops
waiting and leaves the job running; actbreak clean reaps it afterwards. A
session parked from a multi-breakpoint run --no-attach instead re-parks at
the next breakpoint if the job reaches it before finishing, so resume steps
through them one at a time the same way attaching would.
actbreak init-vscode
Scans every .github/workflows/*.yml (and .yaml) and writes one VS Code
task per step: actbreak: <workflow> / <job> / <step>, each running the real
actbreak run <workflow> --break-before "<job>:<index>" command in the
integrated terminal. Instead of typing the step selector by hand, open the
command palette (Cmd/Ctrl+Shift+P → "Tasks: Run Task") and pick the step.
Reruns are idempotent: a second init-vscode replaces only the tasks it
generated last time (matched by the actbreak: label prefix) and leaves
every other task in .vscode/tasks.json untouched. If that file already has
// comments or a trailing comma (both legal in VS Code's own format, not in
plain JSON), it's left alone entirely and the generated tasks go to
.vscode/actbreak-tasks.json instead, for you to merge in by hand.
actbreak --completions bash (or zsh) prints a completion script built
from the argparse parser, so new flags show up without touching it:
# bash
source <(actbreak --completions bash)
# zsh
source <(actbreak --completions zsh)For zsh, the persistent version is a file in your $fpath, which is what
#compdef at the top of the script is for:
mkdir -p ~/.zfunc
actbreak --completions zsh > ~/.zfunc/_actbreak
# in ~/.zshrc, before compinit:
# fpath=(~/.zfunc $fpath)Either way you need compinit to have run, which most zsh setups (and
oh-my-zsh) already do.
actbreak steps ci.yml
actbreak run ci.yml --break-before "Run tests"
actbreak run ci.yml --job build --break-before build:2
actbreak run ci.yml --break-after "Build" --no-attach
actbreak run ci.yml --break-before "Install deps" --break-after "Build"
actbreak run ci.yml --break-on-failure
actbreak run ci.yml --break-before "Run tests" --shell zsh
actbreak run ci.yml --break-before "Run tests" --matrix os:ubuntu-latest --matrix python:3.12
- Finds the target workflow and, using the given job/step selector, resolves an exact step in it.
- Copies the workflow to a temp file and splices a synthetic step in immediately before or after the target, using line-based text injection (never a YAML parse-and-re-serialize round trip; see below for why).
- The injected step drops a sentinel file (
/tmp/actbreak/hold) and blocks on it inside the container. It carriesif: always(), so the breakpoint still holds when an earlier step has already failed, which is the case you most want a shell for. - Runs
act -W <temp copy> --reuseso the container stays alive after the run "finishes" (i.e. hangs at the hold). - Polls
docker ps/podman psfor the job's container, then for the sentinel file, to know the breakpoint has been hit. - Execs an interactive shell into the container. Exiting the shell (or
running
actbreak resume) deletes the sentinel and lets the job continue.
- A matrix job needs
--matrixto pick one leg. act runs one container per leg and they all carry the same job id, so actbreak can't tell them apart and refuses to guess.--matrix KEY:VALUE, given once for each matrix key, is passed straight to act so only that leg runs. Without it, actbreak names the containers and prints thedocker exec/podman execcommand for each, so you can attach to the leg you want by hand. - The breakpoint step needs a real shell in the job container: it runs
shwithmkdir,printf, andsleep. Ascratchor distroless image without those won't hold at the breakpoint. - act names a job's container after the job's
name:when it has one. Aname:with an expression in it, likeTests (${{ matrix.os }}), is filled in by act first, so actbreak can't predict the container's name.runwarns about it up front, and may wait until--timeoutruns out. Take the expression out ofname:while you debug that job. - Attaching tries
sh, thenbash, unless--shellsays otherwise. An image that needs something else (zsh, or a login shell viabash -l) needs--shellset explicitly. act --reusekeeps the job container alive so you can attach to it, and it stays running after act exits. actbreak reaps that container once the run finishes cleanly (resumed to the end, the breakpoint never hit, or a--break-on-failurerun that passed), so a normal run doesn't leave one behind.resumeknows the job is done when act's process exits. It can't check that on Windows, so there it waits out its 30-minute limit; Ctrl-C andactbreak cleanend it sooner. The ones it keeps on purpose are recorded soactbreak listshows them andactbreak cleanremoves them: a--no-attachbreakpoint, parked foractbreak resumeto pick up later, a--break-on-failure --no-attachpost-mortem container, and the containers a failed--break-on-failurerun leaves when several jobs are still alive and it has no single one to attach to.
Because round-tripping a workflow through a generic YAML library corrupts it.
PyYAML's default loader coerces an unquoted on: key to the boolean True
under YAML 1.1 rules, and any generic dumper throws away comments, quoting
style, and anchors. actbreak never deserializes the file. It scans for
jobs:, then the target job, then its steps: list, using indentation alone,
and splices in new lines at the right point. Every other byte in the file is
untouched.
pip install -e ".[dev]"
python -m unittest
pytest
The integration pytest marker (pytest -m integration) runs a real
act + Docker/Podman end-to-end test; it's auto-skipped unless both are on
PATH, which in practice means it only runs in CI.
What is left needs the maintainer, or people running actbreak on their own workflows.
- Getting 0.3.0 onto PyPI. The tag and the GitHub release exist; PyPI rejected the earlier uploads because the project there does not trust the release workflow yet. Adding that is a setting on pypi.org behind the maintainer's login. Until then, install from git as Install says.
- Reports from real projects. CI runs the commands against real act, but only on a few small workflows and one act version (see Status). Big workflows, Podman, and other act versions are untried. If something misbehaves, an issue with the workflow and your act version helps the most.
GPL-3.0-or-later. You can use, study, change and share it. If you distribute a copy or a modified version, it has to stay under the GPL and come with its source. Releases up to and including v0.2.0 were under the Prosperity Public License 3.0.0. The code sat under MIT on main for a while after that, but no release was cut under MIT.
If actbreak saved you a round of push-and-pray debugging, sponsoring is what keeps it maintained.