reenact

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: MIT

README

reenact

CI Release

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
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Directories

Path Synopsis
cmd
reenact command
Command reenact reproduces a failed GitHub Actions run locally and drops you into a shell right before the step that broke.
Command reenact reproduces a failed GitHub Actions run locally and drops you into a shell right before the step that broke.
internal
corpus
Package corpus reads the corpus of real runs produced by tools/fixtures.py from disk and implements gh.API on top of it.
Package corpus reads the corpus of real runs produced by tools/fixtures.py from disk and implements gh.API on top of it.
discover
Package discover turns "the branch I am on" into a run reference, so that `reenact last` needs no URL.
Package discover turns "the branch I am on" into a run reference, so that `reenact last` needs no URL.
event
Package event reconstructs a run's event payload.
Package event reconstructs a run's event payload.
expr
Package expr handles GitHub Actions expressions (${{ ...
Package expr handles GitHub Actions expressions (${{ ...
fidelity
Package fidelity collects the differences between the local reproduction and the real CI run.
Package fidelity collects the differences between the local reproduction and the real CI run.
gh
Package gh wraps the part of GitHub's REST API that reenact needs.
Package gh wraps the part of GitHub's REST API that reenact needs.
ghurl
Package ghurl parses GitHub Actions run and job URLs.
Package ghurl parses GitHub Actions run and job URLs.
image
Package image resolves a runner label to a Docker image.
Package image resolves a runner label to a Docker image.
jobmatch
Package jobmatch ties a job reported by the API to its definition in the YAML and, crucially, to THE MATRIX LEG THAT FAILED.
Package jobmatch ties a job reported by the API to its definition in the YAML and, crucially, to THE MATRIX LEG THAT FAILED.
resolve
Package resolve turns a run URL into ALL the data needed to reproduce it.
Package resolve turns a run URL into ALL the data needed to reproduce it.
runner
Package runner actually executes the synthesized plan: it prepares the workspace, invokes act, finds the container and drops the user into a shell in front of the failing step.
Package runner actually executes the synthesized plan: it prepares the workspace, invokes act, finds the container and drops the user into a shell in front of the failing step.
stepmap
Package stepmap turns the steps reported by the API into YAML step indices.
Package stepmap turns the steps reported by the API into YAML step indices.
synth
Package synth produces, from a resolve result, a TEMPORARY workflow that act can run.
Package synth produces, from a resolve result, a TEMPORARY workflow that act can run.
wf
Package wf models workflow YAML, but only as far as reenact needs it.
Package wf models workflow YAML, but only as far as reenact needs it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL