reenact

Drop into a shell at the exact step where your CI run failed.
$ reenact https://github.com/owner/repo/actions/runs/123/job/456
Commit a1b2c3d · job "test (node 20, ubuntu-24.04)" · failing step 7/11
breakpoint reached in 14s
6 step(s) ran. the next step is the one that failed:
Run npm run integration
root@runner:/…/repo$
act answers "run my workflow locally". reenact answers "why did that particular run fail?".
It takes the URL of a failed run, reconstructs the commit, matrix leg, runner image and event
payload that run used, and leaves you in a shell just before the step that broke.
act (MIT) does the executing. What reenact adds is the resolver, the step mapper and the
fidelity report.
|
act |
reenact |
| Input |
a workflow file |
a failed run URL |
| Environment |
you guess the image |
resolved from the run |
| Code |
your working tree |
the run's exact commit |
| Matrix |
picked by hand |
the leg that actually broke |
| Event context |
hand-written JSON |
reconstructed from the API |
| Goal |
run the workflow |
land you before the failing step |
Install
gh extension install waznggo/gh-reenact # as a gh extension
curl -fsSL https://raw.githubusercontent.com/waznggo/reenact/main/install.sh | sh
go install github.com/waznggo/reenact/cmd/reenact@latest # from source
Linux and macOS, amd64 and arm64. Windows binaries are published as well, though what
reenact reproduces is Linux jobs; it needs Docker wherever you run it.
inspect needs only a GitHub token, which gh auth login provides. Reproducing a run
additionally needs Docker and act.
Installed as a gh extension the command becomes gh reenact <run-url>, and the help text
follows the name it was invoked under so copied examples work either way.
Scope
Measured across 243 real failed jobs from 15 popular repositories:
|
share |
status |
| Plain Ubuntu jobs |
63% |
supported |
| Reusable workflows |
13% |
supported; exact when the call is same-repo or pinned to a SHA |
| macOS / Windows |
23% |
cannot be reproduced in a Linux container, and are refused |
| Unknown runner labels |
1% |
resolved by asking |
That comes to roughly 76% of real CI failures. Where a run cannot be reproduced faithfully,
reenact says so rather than producing something that merely looks like success.
Status
reenact resolves a failed run, reproduces it in a container, and reports every way the
local run differs from CI. Reusable workflows are supported.
$ reenact https://github.com/sharkdp/fd/actions/runs/30123809942/job/89582415206
Job Ensure 'cargo clippy' has no warnings
Failing step 3/3 — "Run cargo clippy --all-targets --all-features -- -Dwarnings"
Plan 2 step(s) will run, then a shell opens before:
Run cargo clippy --all-targets --all-features -- -Dwarnings
Fidelity ✓ 3 check(s), nothing that should change behaviour
cloning ca51233 from github.com/sharkdp/fd
breakpoint reached in 13s
2 step(s) ran. the next step is the one that failed:
Run cargo clippy --all-targets --all-features -- -Dwarnings
run it again with: bash /tmp/reenact-failing-step.sh
root@runner:/tmp/reenact-.../repo$ bash /tmp/reenact-failing-step.sh
error: could not compile `fd-find` (test "tests") due to 14 previous errors
That last line is byte-for-byte what the real CI log says.
| Flag |
Behaviour |
| (default) |
run the steps before the failure, then open a shell |
--replay |
run the failing step too, then open the shell |
--step <n> |
stop before this YAML step index instead |
--dry-run |
print the generated workflow and act command; clone nothing, run nothing |
--keep |
keep the container and workspace after you exit |
--repo <path> |
clone from a local repository (- forces GitHub) |
--verbose |
show act's raw output instead of the step progress |
--secret NAME=value |
provide a secret (repeatable) |
--secret-file <path> |
read secrets from a file, one NAME=value per line |
The URL can be skipped entirely: reenact last reproduces the newest failed run
on the current branch (the repository is read from the origin remote), and
reenact inspect last resolves it without running anything. The flow it serves
is the common one — you push, CI fails, you type reenact last in the same
terminal you pushed from.
Reproduction needs Docker and act. Your own
working tree is never touched: the commit is cloned into a temporary directory,
and everything is removed when you exit.
The fidelity report
The hard question about any local reproduction is whether a pass here means anything on CI.
So reenact states, up front, every way local differs from the real run:
Fidelity
✓ Commit fdd20a0 — the run's exact commit is checked out, and
`git` works inside the container
· Image ghcr.io/catthehacker/ubuntu:act-24.04 — the closest
community image for ubuntu-latest; GitHub does not
publish its runner images, so preinstalled tool
versions can differ
⚠ Event pull_request, only partially reconstructed (13 fields);
steps reading github.event.* may see empty values
· Cache actions/cache, actions/setup-node (cache: input)
restored a cache in CI; the local cache starts empty,
so this run builds from scratch (act keeps its own
cache, so a second run of this job is warm)
✗ Upstream jobs this job needs build and downloads an artifact from
them; those jobs are not run, so the artifact does not
exist
→ build the artifact by hand inside the container, or reproduce
the upstream job first
✓ same as CI · · different but harmless · ⚠ may change behaviour · ✗ will change it.
For pull_request runs that carry no PR reference (common for fork PRs), the PR
is found by walking from the run's head commit back to its pull requests, so
github.event.pull_request is filled in rather than left empty. When the PR has
newer commits than the run, the report says so instead of pretending the payload
is exact.
Two decisions here came out of measurement rather than intuition:
- Findings are scoped to the steps that actually run (index 0 through the failing step). In the
corpus, 20 of 56 jobs reference a user secret somewhere in the workflow file, but only 3
reference one in the failing job, and in all 3 it sits after the breakpoint. A file-wide scan
would have cried wolf 20 times.
- The wording follows the measured frequencies, not guesses: dropped
needs 28.6%, cache 25%,
ignored fail-fast 23%, ignored timeout-minutes 20%, GITHUB_TOKEN 3.6%, upstream artifact
3.6%, user secrets 0%.
What it refuses to do
The workflow being reproduced comes from a repository you did not write, so a few things are
treated as untrusted input:
uses: is validated before it becomes an API path. A crafted value containing .. or ?
would otherwise send a request with your token to a different endpoint, and the result would be
printed back to you by --dry-run. Anything that is not owner/repo/path.yml@ref is refused.
- YAML alias expansion is bounded. Parsing a workflow is cheap because aliases are not expanded,
but generating one expands them. Measured, a 7-level alias bomb took 640 ms and each further
level multiplies by nine. There is a node budget now, and exceeding it is a loud error rather
than a silent hang.
- Secret values never touch the command line or the report. They go into a
0600 file passed as
--secret-file, are deleted on exit, and --dry-run never writes them at all. act's log is
0600 inside a 0700 workspace.
- The GitHub token is never passed into the container. That gap is reported as a fidelity
finding instead of being papered over.
Secrets are never read from the GitHub API, which does not expose their values by design. Pass what a
step needs with --secret NAME=value or --secret-file; the values go into a 0600 file inside the
temporary workspace, never onto the command line where ps would show them, and are deleted on exit.
Resolve only
reenact inspect needs only network access, no Docker:
$ reenact inspect https://github.com/sharkdp/fd/actions/runs/30123809942/job/89582415206 --steps
Run #2046 · push · master
Commit ca51233 Merge pull request #2073 from sharkdp/dependabot/...
Job Ensure 'cargo clippy' has no warnings
Runner ubuntu-latest → ghcr.io/catthehacker/ubuntu:act-24.04 (known GitHub label)
Workflow .github/workflows/CICD.yml → job `lint_check`
Failing step 3/3 — "Run cargo clippy --all-targets --all-features -- -Dwarnings"
Event push, reconstructed (36 fields)
Secrets none referenced
Steps 3 mapped to YAML, 3 synthetic skipped
api# kind yaml# name
1 synthetic — Set up job
2 exact 0 Run dtolnay/rust-toolchain@stable
3 exact 1 Run actions/checkout@v7.0.1
4 exact 2 ✗ Run cargo clippy --all-targets --all-features -- -Dwarnings
8 synthetic — Post Run actions/checkout@v7.0.1
9 synthetic — Complete job
Ready: 2 step(s) would run, then a shell opens before step 3.
Note the step numbers: the API reports 4 for the failing step, but its position in the YAML
is index 2. They are not the same thing; see below.
Exit codes: 0 reproducible · 1 error · 3 resolved but not reproducible.
--json emits the same data for scripting.
Verified, not assumed
Every rule in the resolver was measured against a corpus of real failed runs before it was
written. The corpus (58 runs, 1167 jobs, 102 failures) is committed under testdata/, so the
test suite runs offline and deterministically.
| Claim |
Measured |
| Matrix leg identified correctly |
72/72 jobs |
Resolved leg's runs-on matches the API's labels |
61/61 (independent cross-check) |
| Breakpoint found for in-scope jobs |
56/56 |
| Steps left unexplained |
0 of 830 |
| Out-of-scope jobs rejected explicitly |
46/46 |
| Generated workflows satisfying all 9 invariants |
56/56 |
| Pinned matrix keys matching the resolved leg |
56/56 |
| Fidelity findings agreeing with the independent Python probe |
cache 14/14 · needs 16/16 · token 2/2 · secrets 0/0 |
| act's built-in cache server verified end to end |
6/6 checks |
Release artifacts satisfying gh extension install's rule |
17/17 checks |
| act's reusable-workflow behaviour verified before synthesis was written |
7/7 checks |
| Jobs with a breakpoint (after reusable support) |
78 of 79 in scope |
What measurement changed
-
API step numbers are not YAML step indices, in 92% of jobs. Numbers skip (1,2,5,6,7,11,15)
and the runner injects steps that exist in no YAML file (Set up job, Post *, Complete job).
A naive "truncate at step N-1" cuts in the wrong place almost every time.
-
Reusable workflows with moving refs. When a job is uses: org/repo/.github/workflows/x.yml@main,
the steps live in another repository at a ref that has moved since. Exact fidelity is
theoretically impossible for past runs; reenact says so instead of pretending.
-
Runner labels are messy. Third-party providers (namespace-profile-default,
blacksmith-4vcpu-ubuntu-2404) and ARM runners are common, so image resolution is four-layered
and asks rather than guesses when it runs out of rules.
-
git is dead inside the container, and the obvious diagnosis was wrong. A git worktree
stores .git as a file pointing at a host path, which breaks in the container; but measuring
act's actual behaviour (tools/act_probe.sh) showed the deeper cause: act copies the working
directory and never carries .git across at all. The fix needs both halves: a real
git clone --local and act's --bind. Then git rev-parse HEAD inside the container prints
the right SHA.
-
A job name containing " / " does not mean the job calls another workflow. That was the
detection rule, and it silently rejected 15 ordinary jobs in the corpus. pnpm's job is simply
named Ecosystem E2E / ${{ matrix.stack }}. The reliable signal is uses: in the YAML. The
caller's own name can contain a slash too (TS CI / Test / ubuntu), so the split point is
searched rather than assumed.
Where the rules run out, reenact does not guess: it reports the candidates and asks.
How measurement shaped it
Each stage was measured before it was built, and the measurements repeatedly overruled the
plan. Four examples, all reproducible from tools/:
- A secrets keyring was dropped. User secrets turn up in the running steps of 0 of 56
failed jobs: they belong to deploy jobs, while the jobs that fail are builds and tests.
--secret and --secret-file cover the remaining need.
- A cache server was dropped. act already ships one, on by default;
tools/cache_probe.sh
confirms a real hit on the second run and that the restored content is what the first run
wrote.
- Packaging was dictated by
gh's own source, which installs the first release asset whose
name ends with <os>-<arch>, unpacked. Extension binaries therefore use hyphens and the
human tarballs underscores, so gh can never mistake one for the other.
tools/dist_probe.sh replays that rule against the built release.
- Reusable-workflow support started by finding a bug worth more than the feature: whether a
job called another workflow was decided by looking for
" / " in its name, which rejected
15 ordinary jobs whose names merely contain a slash. The reliable signal is uses: in
the YAML. The same measurement showed that 10 of 14 real calls are same-repo, so the file
is the one the run used and fidelity is exact.
Homebrew was deliberately left out: a tap is a third repository to keep alive, and the people
who debug CI already have gh.
Full documentation is in the wiki (mirrored in docs/):
how it works ·
the fidelity report ·
scope and limits ·
troubleshooting ·
how everything was measured.
Build from source
git clone https://github.com/waznggo/reenact
cd reenact
go build ./cmd/reenact
./reenact inspect <run-url>
Requires Go 1.26 or newer and a GitHub token; gh auth login is enough, or set GITHUB_TOKEN.
Repo layout
cmd/reenact/ CLI and report rendering
internal/ghurl/ run URL parsing
internal/discover/ `reenact last`: origin remote + branch -> newest failed run
internal/gh/ GitHub API client (network only, never executes anything)
internal/wf/ workflow YAML model, matrix expansion, display-name rules
internal/expr/ ${{ }} handling: matched backwards, never evaluated
internal/jobmatch/ API job -> YAML job + matrix leg
internal/stepmap/ API steps -> YAML step indices -> breakpoint
internal/image/ runner label -> Docker image (4 layers)
internal/event/ event payload reconstruction
internal/resolve/ orchestrator, reusable calls, and the offline corpus test
internal/synth/ resolved run -> temporary workflow (pure, no I/O)
internal/fidelity/ local vs CI differences, scoped to the steps that run
internal/runner/ clone, act, container, shell, cleanup (all execution risk)
internal/corpus/ offline fixture reader shared by the acceptance tests
tools/ measurement scripts; the numbers above come from these
testdata/corpus/ 58 real failed runs (+ the workflows they call), for offline tests
docs/ wiki pages, versioned with the code
packaging/ the gh-reenact mirror repository, ready to push
.goreleaser.yaml release packaging; the naming rules are gh's, not ours
install.sh curl installer, verifies sha256 before writing anything
The probes in tools/ are reference implementations: a rule is measured there against real data
first, then ported to Go. act_probe.sh and cache_probe.sh do the same for act itself: run
them after an act upgrade to see which assumption broke. dist_probe.sh does it for gh: it
replays gh's asset-selection code against the built release, so a packaging change that would
break gh extension install fails the release workflow instead of shipping.
Roadmap
Four things are designed but not yet built. Everything here follows the same
rule that shaped the rest of the tool — measure first. The first two have had
their measurements done; the probes and their results are committed in
tools/:
- Seeding upstream artifacts from the real run. A job that
needs another
and downloads its artifact currently gets a ✗ finding. Measured
(tools/artifact_probe.sh): act's artifact server keeps its state on disk at
<path>/1/<name>/<name>.zip, a fresh invocation downloads a pre-placed
artifact, and the GitHub API serves a real run's artifact as a zip of the
same shape — the seeding path is open.
- Comparing the replayed failure against the CI log. Measured
(
tools/joblog_probe.py): the failing step can be sliced out of the job log
at its ##[group] marker, and with timestamps and colour codes stripped the
final error line matches a local reproduction byte for byte. What remains is
wiring the comparison into --replay.
- Verifying a fix in the run's environment. Today reenact drops you in
front of the failure; proving your fix works still costs a push and a CI
round-trip. A mode that runs the same job in the same environment but with
your working tree as the code would close that loop locally. Needs its
probe first: how act's
--bind behaves on a dirty tree.
reenact watch. Start it after a push: if the run goes green it exits
quietly; if it fails, reenact reproduces it immediately, so the shell is
already waiting by the time you have read the log.
One thing deliberately not on the list: a pre-push "run my workflow
locally" mode. That is act's job, and it does
it well; reenact exists for the run that already failed.
Contributing
See CONTRIBUTING.md — it is short, and it explains the one
rule that makes this codebase unusual: claims are measured before they are
built, and the probes that measured them are committed. Bug reports with a run
URL that resolves wrongly are the most valuable kind.
License
MIT.