A TUI-first, interactive step-debugger for GitHub Actions workflows that runs them
locally — pause before any step, inspect the environment, drop into the job container,
re-run a step — with faithful uses: execution, because it stands on
nektos/act instead of reimplementing the Actions engine.
actl runs a real GitHub Actions job locally through act's engine and lets you debug it
like code — instead of the push-and-pray loop of editing YAML and waiting on CI:
- ⏸ Pause before or after any step — set breakpoints and run-to-cursor, or single-step the whole job. Stop before anything runs, just like you can't on GitHub.
- 🐚 Drop into the live job container mid-run — a real shell in the same container act execs steps into, with the workspace and env exactly as the next step will see them.
- 🔍 Inspect the environment at any pause — the job-scoped env the next step runs with.
- ✏️ Edit a step's command or env and re-run it in place — no commit, no push, no re-trigger.
- 🎯 Faithful
uses:— docker / composite / node actions run through act's real engine, not a partial reimplementation. - 📥 Faithful
actions/checkout— intercepted to copy your working tree (including uncommitted changes) at the checkout step's position, so steps before it see an empty workspace and steps after see your code, exactly as on GitHub. - ☁️ Cloud identity, locally — federated
gcp,awsandazurelogins are rewritten to run the real login action under a scoped credential you bring, so the auth step just works. - 🧩 Real-workflow ergonomics — job selection, matrix pinning,
services:, secrets / vars / env,needsseeding, GitHub runtime-context seeding, per-environment:overlays, a committable.actl.yml, and a Docker-free-listinventory.
Every substitution actl makes prints a transparency line — it tells you exactly where local execution differs from CI (whose identity you're running as, what it seeded, what it mounted), so you're never silently testing something other than your workflow.
All three routes need Docker at runtime — actl starts a real job container via act.
Homebrew (macOS):
brew install ruzmuh/actl/actl(A Homebrew cask — macOS only. On Linux, use a prebuilt binary or build from source.)
Prebuilt binary (macOS & Linux, amd64/arm64): grab the archive for your platform from the latest release, then:
tar -xzf actl_*_$(uname -s)_$(uname -m).tar.gz
sudo mv actl /usr/local/bin/ # or anywhere on your PATHFrom source: the act fork is a git submodule wired in via a filesystem replace in
go.mod, so go install github.com/ruzmuh/actl/...@latest won't work (it can't resolve
the local replace). Clone with the submodule first, then install from the checkout:
git clone --recurse-submodules https://github.com/ruzmuh/actl
cd actl
go install ./cmd/actl # builds with the submodule on disk; binary lands in $GOBINRequires Go (the module pins the toolchain to match act; go auto-fetches it).
Run actl from inside your repo. With a single .github/workflows/*.yml it's picked up
automatically; with several, pass the path. actl debugs one job at a time.
# look inside first — jobs, steps, matrix, environments (no Docker, no network):
actl -list
actl -list .github/workflows/ci.yml # if you have more than one workflow
# debug a job — pauses before the first step:
actl -job build
actl -job build .github/workflows/ci.ymlIn the TUI: s step · c continue · d drop into the container shell · e env pane ·
r re-run the step · q quit (full key list).
Need inputs, an event, secrets, or a pinned image? Drop a committable .actl.yml
next to your workflow instead of a wall of flags — actl auto-discovers it:
# .actl.yml
job: build
event: workflow_dispatch
inputs: # for workflow_dispatch / workflow_call
version: "1.2.3"
secret-file: .secrets # gitignored file; secrets can't be inlined here
vars:
REGION: us-east-1Now a bare actl uses it; any CLI flag still wins for a one-off override.
A few common scenarios:
# a deploy job that depends on others — seed what upstream produced…
actl -job deploy -need 'build.outputs.image=ghcr.io/acme/app:1.4.2'
# …or run the upstream jobs for real, then debug deploy:
actl -job deploy --with-deps
# a job that logs into the cloud — bring a scoped credential, the real login action runs:
actl -job deploy -gcp-key-file ~/sa-key.json # rewrites the federated auth step, faithfully
# a matrix job — pin one combination (-list shows them):
actl -job test -matrix 'os=ubuntu-latest' -matrix 'go=1.22'Full detail on every flag and .actl.yml key is in Usage below.
Examples use the installed
actlbinary. Working from a clone instead? Swapactlforgo run ./cmd/actl(see Develop).
When paused: s/enter step · c continue · g run-to-cursor · ↑↓/jk move cursor ·
b toggle breakpoint · e env pane · i edit step command · E edit job env ·
r re-run the step in the live container · d drop into a shell in the container · q quit.
The log pane scrolls any time (paused or running): PgUp/PgDn page, ctrl+u/ctrl+d
half-page, home/end jump to top/bottom, mouse wheel. The run halts before the first
step; break-on-error halts after a failing step.
-list inventories a workflow — its jobs, each job's steps, any matrix combinations, and
the deployment environment: — and exits without running Docker or shelling out for
identity. Use it to discover the job and step names you'll target.
actl -list testdata/workflows/pipeline.ymlactl debugs one job at a time, in isolation — the job's upstream needs jobs are not
run. Pick the job, and seed the upstream outputs/results you want it to see; the same flags
on the command line mean a re-run reproduces the exact state.
actl testdata/workflows/pipeline.yml # lists jobs if there's more than one
actl -job deploy testdata/workflows/pipeline.yml
# seed what the upstream job would have produced (paths mirror the needs.* context):
actl -job deploy \
-need 'build.outputs.image=ghcr.io/acme/app:1.4.2' \
-need 'build.result=success' \
-env 'STAGE=prod' \
testdata/workflows/pipeline.ymlUnseeded outputs resolve to empty (exactly as a non-existent output does in GitHub); an
unseeded result defaults to success. The TUI prints a transparency line per need so you
see precisely what the isolated run stands on.
Prefer to exercise the real dependencies instead of seeding them? --with-deps runs the
upstream jobs for real to completion first, then pauses only on the target job's steps — so
needs.* are genuine and there's nothing to seed (upstream output streams to the log pane):
actl -job deploy --with-deps testdata/workflows/pipeline.ymlA job whose matrix expands to more than one combination must be pinned to exactly one —
-list shows the combinations, and -matrix KEY=VALUE (repeatable) selects it:
actl -job test \
-matrix 'os=ubuntu-latest' -matrix 'go=1.22' \
testdata/workflows/matrix.ymlThe single -image default maps ubuntu-latest; to map other runner labels to images use
-platform LABEL=IMAGE (repeatable, act's -P; overrides the images: map in .actl.yml).
When a job declares services:, act starts those service containers and the TUI prints a
line naming them.
actl reads act's dotenv triple from the working dir — .secrets → secrets.*,
.vars → vars.*, .env → env vars — so ${{ secrets.X }}, ${{ vars.X }} and $X
resolve as on GitHub. These files are gitignored; keep them out of commits. Override
individual keys with repeatable -secret/-var/-env KEY=VALUE (these win over the
files), or point at a file outside the repo with -secret-file/-var-file/-env-file.
printf 'TOKEN=s3cr3t\n' > .secrets # gitignored
printf 'REGION=eu-west-1\n' > .vars
printf 'STAGE=dev\n' > .env
actl testdata/workflows/config.yml
# keep secrets outside the repo and override one key for this run:
actl -secret-file ~/.config/actl/demo.secrets \
-var 'REGION=us-east-1' testdata/workflows/config.ymlThe TUI prints a redacted transparency line naming what loaded — counts and names only, never values — and act masks secret values in the step logs.
For a real workflow, stash the debug slice in a committable .actl.yml instead of a
flag soup. It's auto-discovered as .actl.yml in the working dir (point elsewhere with
-config FILE). Precedence is CLI flag > .actl.yml > built-in default, and unknown
keys are rejected so typos surface immediately.
# .actl.yml — every key optional
workflow: .github/workflows/deploy.yml # a path arg still wins
job: deploy
event: push
matrix: # pin one combination
os: ubuntu-latest
with-deps: false # true = run upstream needs for real first
images: # act's -P: runner label -> docker image
ubuntu-latest: catthehacker/ubuntu:act-latest
ubuntu-22.04: catthehacker/ubuntu:act-22.04
breakpoints: # step index OR step name
- 0
- "Build"
# workdir: . # bind-mount this dir (writable) so local 'uses: ./' resolve
# source: . # tree a default actions/checkout copies from
secret-file: .secrets # secrets are FILE-ONLY here (see below)
vars:
REGION: us-east-1
env:
LOG_LEVEL: debug
inputs: # workflow_dispatch / workflow_call
version: "1.2.3"
# needs: # seed upstream needs for isolated debugging
# lint:
# result: success
# outputs: { sha: abc123 }
identity: # cloud auth — bring a scoped credential
gcp: { file: .secrets.gcp.json } # SA key → rewrites a federated google-github-actions/auth
aws: { file: .secrets.aws.env } # AWS_ACCESS_KEY_ID/SECRET dotenv → static-key rewrite
azure: { file: .secrets.azure.json } # SP creds → rewrites a federated azure/login
# gcp: { ambient: true } # opt-in instead: use your gcloud ADC (GCP/AWS only)Because .actl.yml is committable, secrets can't be inlined — an inline secrets:
map (top level or under an environment) is a hard error; reference a gitignored dotenv via
secret-file: instead. vars/env are not sensitive and may be inlined. A copy with
inline comments lives in .actl.yml.sample.
GitHub scopes secrets.*/vars.* by deployment environment:. When the debugged job
targets one, the matching block under environments: overlays the flat secret-file/
vars defaults (a CLI -secret/-var still wins). The TUI prints which overlay loaded
(counts only):
environments:
production:
secret-file: .secrets.prod
vars: { REGION: us-west-2 }
staging:
vars: { REGION: eu-west-1 }GitHub injects context a clean local runner lacks; actl seeds it and prints a
transparency line for each:
github.token/secrets.GITHUB_TOKEN— from-github-token, else aGITHUB_TOKENin.secrets, else ambientgh auth token; the two stay equal as on GitHub. It is not auto-exported as$GITHUB_TOKEN(faithful — map it viaenv:). Heads-up: your token's scope differs from CI's ephemeral, repo-scoped one.- Workflow inputs —
-input NAME=VALUE(repeatable) forworkflow_dispatch/workflow_call; act applies the declareddefault:and boolean typing itself, so you only supply the values you want to override. - Event payload —
-event-file PATH.jsonsetsgithub.event.*. github.*context —-repository/-ref/-sha/-actoroverride the respective fields; otherwise repository/ref/sha are derived from your local git (originremote, HEAD).actor,run_id, andrun_numberare honest placeholders.
By default the job runs with an empty workspace (the repo is kept out of the container),
so remote uses: actions work but local uses: ./… actions and actions/checkout of the
working repo won't find any files — the TUI flags this when it spots local actions. That's the
common case; reach for -workdir only when you actually have local actions to debug.
-workdir DIR bind-mounts DIR as the workspace so local actions resolve. Note the
tradeoff: a mounted workspace is writable, so steps running in the container can change
your working tree (build artifacts, generated files). The TUI shows a transparency line when
a workspace is mounted.
actl -workdir . path/to/workflow.ymlA default actions/checkout (no ref/repository/path) would clone a remote over the
workspace — losing your local changes. actl intercepts it: it copies your working tree
(current dir, or -source DIR) into the workspace at the checkout step's position, honouring
.gitignore and without mounting (no host writes). Steps before checkout still see an empty
workspace, exactly as on GitHub; steps after see your code, including uncommitted changes.
Git submodules follow the step's submodules: input (off by default, as on GitHub; true/
recursive copies them in). A checkout pinned to another repo/ref/path is left as a real clone.
actl testdata/workflows/checkout.ymlIn real CI, a login action (google-github-actions/auth, aws-actions/configure-aws-credentials,
azure/login) plus id-token: write mints a GitHub-signed OIDC token and exchanges it for
short-lived cloud credentials. Locally there is no GitHub OIDC issuer, so a federated
step can't authenticate — it would fail and kill the job.
actl's default is bring a scoped credential: you supply a service-account key /
service-principal secret / static keys, and actl rewrites the federated step to its
secret/key mode (referencing the credential as a masked secret) and runs the real login
action — so it authenticates faithfully, under a real non-personal identity whose actual
permissions are exercised. A step that already uses secret/key mode runs untouched. The
TUI prints a transparency line for each — what it would federate as vs how actl satisfied
it. (An opt-in ambient fallback runs steps under your personal login instead; see below.)
GCP — bring a service-account key for the federated google-github-actions/auth:
actl -gcp-key-file ~/sa-key.json testdata/workflows/gcp-auth.ymlactl doesn't inject a project — that's the workflow's concern, exactly as on GitHub
(the command's --project, or a GOOGLE_CLOUD_PROJECT you pass via .env / -env).
Opt-in ambient fallback: -gcp-ambient mounts your gcloud Application Default Credentials
(gcloud auth application-default login first; -gcp-credentials FILE to point elsewhere)
and injects them — note this puts a re-mintable refresh-token credential in the container.
AWS — bring static keys (a dotenv with AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)
for the federated aws-actions/configure-aws-credentials; the declared aws-region is kept:
actl -aws-keys-file ~/aws-debug.env -image catthehacker/ubuntu:full-latest testdata/workflows/aws-auth.ymlCredentials are used directly (no sts:AssumeRole — the CI role's trust policy won't trust
your principal). Opt-in ambient fallback: -aws-ambient (with -aws-profile NAME) injects
your ambient AWS session (env-only).
Azure — bring a service-principal creds JSON (az ad sp create-for-rbac --json) for the
federated azure/login; actl rewrites it to creds mode and runs the real action:
actl -azure-creds-file ~/azure-sp.json -image catthehacker/ubuntu:full-latest testdata/workflows/azure-auth.ymlA creds-mode azure/login (legacy creds: input) runs untouched — just put the secret in
.secrets. Azure has no ambient fallback (it would require mounting your ~/.azure
refresh-token credential). AWS and Azure need the cloud CLI in the runner image, e.g.
catthehacker/ubuntu:full-latest.
The common case: the workflow is written for OIDC federation, but you hold a key/secret for
that identity (or a scoped debug one). Drop the credential file next to the repo and point
.actl.yml at it — then a plain actl rewrites the federated step and runs the real action,
no per-run flags:
# .actl.yml
identity:
gcp: { file: .secrets.gcp.json } # google-github-actions/auth → credentials_json
aws: { file: .secrets.aws.env } # configure-aws-credentials → static keys
azure: { file: .secrets.azure.json } # azure/login → credsactl -job deploy # the federated auth step is rewritten from the configured keyKeep the credential file out of git: naming it .secrets.<cloud>.json is enough — the
repo's .gitignore already covers .secrets.*. The file: path is read relative to the
working dir (an absolute path also works; ~ is not expanded in YAML — that only happens
for a shell-passed -…-file flag). actl loads the file into a reserved secret that act
masks in logs, and references it from the rewritten step. (Equivalent flags for one-off runs:
-gcp-key-file / -aws-keys-file / -azure-creds-file.)
The deprecated
-gcp-identity/-aws-identityflags now alias-gcp-ambient/-aws-ambient.
act already runs Actions workflows locally — but as a batch runner: no breakpoints, no
pause-before-step, no drop-into-shell. actl adds exactly that debug layer, then stays out
of the way of act's engine:
- Reuse, don't rebuild.
act/pkg/modelparses workflows;act/pkg/exprparserevaluates${{ }}. Imported as-is. - Soft fork for the pause hook. act's per-step machinery is unexported, so a tiny patch
interleaves a barrier
common.Executorbetween steps and exposes a resume channel onrunner.Config. The fork lives inthird_party/act, wired in via areplacedirective ingo.mod, pinned to a release. We keep the diff tiny and aim to upstream the hook. - Frontend-agnostic core. The debug engine (
internal/debugger) owns no terminal and imports no frontend; the TUI is one consumer, with headless/agent and DAP front-ends as future peers behind the same API.
cmd/actl/ TUI entry point
internal/debugger/ the pause-barrier core: Session, pause/step/continue, log capture
internal/tui/ Bubble Tea front-end over the core
internal/config/ loads .actl.yml (the debug slice: job/matrix/breakpoints/images/…)
internal/workflow/ thin wrapper over act/pkg/model
third_party/act/ soft fork of act — git submodule → ruzmuh/act (branch actl), pinned by SHA
testdata/workflows/ sample workflows
Requires Go (the module pins the toolchain to match act; go auto-fetches it) and Docker
(act starts a real job container and execs each step into it). The act fork is a git
submodule, so clone with --recurse-submodules (or run git submodule update --init):
git clone --recurse-submodules https://github.com/ruzmuh/actl
cd actl
go run ./cmd/actl # debug the sample workflow in the TUI
go run ./cmd/actl path/to/workflow.yml # your own workflow
go test ./... # run the tests (no Docker needed)MIT, like act.
