NAME

action-run - run a GitHub Actions workflow locally with Docker

SYNOPSIS

action-run [options] <workflow.yml> [project-directory]
action-run [options] check [directory]
action-run [options] preflight <workflow.yml> [project-directory]
action-run [options] schema <check|preflight|cache|logs>
action-run [options] validate <check|preflight|cache|logs> <document.json>
action-run [options] cache [--prune] [--older-than DURATION] [--json]
action-run [options] logs [--prune] [--older-than DURATION] [--json]

DESCRIPTION

action-run reads a GitHub Actions workflow file and executes its Linux jobs inside Docker. Each job gets a private Docker network, any service containers declared by the workflow, and a long-lived job container in which each step is run. Containers and networks are removed when the run finishes, but the images it pulled are kept: they are the next run's cache, so running the same workflow again starts from them instead of downloading them again. Use --no-keep-images to remove them, or --keep to keep the containers too. action-run cache lists what a run kept and how much disk it takes, and action-run cache --prune removes it. --older-than shows or removes only images at least that old.

The jobs of one matrix run beside each other, up to the number of processors on this machine (--parallel), because a matrix is several independent runs that used to wait for one another. Each job has its own container, network and runner directory, and its output is streamed as it is written, prefixed with the job it came from - [test (n=one)] - so two jobs working at once can be told apart. --parallel=off goes back to one job at a time.

The terminal shows those lines as they arrive, interleaved. Each job's own log is copied out of the run's temporary directory before that is removed, and the run ends by printing where the copies are and which job each one holds, so a job can still be read in full - or quoted in a bug report - after its containers are gone. They are kept whether the job passed or failed, and --quiet suppresses the note without suppressing the files. The default directory is $XDG_CACHE_HOME/action-run/logs, or ~/.cache/action-run/logs when that is not set, and ACTION_RUN_LOG_DIR moves it. action-run logs lists those copies with their sizes and ages, and action-run logs --prune removes them; --older-than shows or removes only logs at least that old.

The project directory is optional when the workflow lives inside a .github/workflows directory; the repository root is inferred in that case.

Before any job starts, the same checks as action-run check run: the daemon must be reachable and run Linux containers, and the project directory must be readable through a bind mount. The result is silent when everything works and printed as a report with remedies when it does not, in which case no container is started. Use --skip-check to start the workflow anyway.

The images the run needs are prepared after that and before the first container starts: the job images, the service images and the images of docker:// actions, with matrix values resolved. An image that cannot be pulled stops the run there, naming the job, service or action that needs it. Local composite actions are read at that point as well, including actions used inside other actions, so the images their steps name are prepared before the action is first reached.

A value that only exists once the workflow is running, such as a preceding job's result, cannot be resolved there, so each job makes the same check again, with its own values, before its first container: its job image, its service images and the images of the actions it uses. An image that is unusable is therefore reported before the job starts rather than halfway through it. The one image that cannot be known even then is the one a step decides, because a step output does not exist until its step has run; the job names it as deferred and the step that needs it prepares it before starting its container.

The presence of each image is checked in turn, then the missing ones are pulled at once, four at a time by default, so a wide matrix does not wait for one image after another. --pull-concurrency or ACTION_RUN_PULL_CONCURRENCY sets how many are pulled together; setting it to 1 makes the pulls sequential, which is easier to read when one of them is slow. On Windows they are pulled one at a time whatever is asked for, because Perl's emulation of fork() cannot give a child its own handles.

COMMANDS

check

action-run [options] check [directory]

Reports the host platform, whether the Docker daemon is reachable and can run Linux containers, and whether Docker can bind-mount the host directories that a run needs. The mount probe starts one short-lived container using the image given by --image (default alpine:3.20) and reads a file back from a temporary directory. directory, which defaults to the current directory, is mounted read-only as well and the entries visible inside the container are compared with the host; this catches a drive that Docker Desktop has not shared.

Each problem is followed by the step that fixes it, for example:

problem   the daemon is not reachable; is Docker running and can this user access it?
remedy    start Docker Desktop and wait until it is running, then try again
remedy    if the engine will not start, run "wsl --shutdown" and start Docker Desktop again

check exits 0 when the environment is ready and 2 when a problem was found. With --json it prints a single JSON object instead of the text report, for a CI job to assert on:

{
  "schema_version": 2,
  "ready": true,
  "problems": 0,
  "host": { "os": "linux", "name": "Linux", "perl": "5.40.0", "archname": "x86_64-linux" },
  "docker": { "bin": "docker", "client": "29.9.1", "daemon": "29.9.1", "containers": "linux" },
  "image": "alpine:3.20",
  "mounts": [
    { "host": "/tmp/action-run-check-XXXX", "container": "/action-run-check", "ok": true },
    { "host": "/src/project", "container": "/check-project", "entries": 17, "expected": 17, "ok": true }
  ],
  "issues": [ { "problem": "...", "remedies": [ "..." ] } ]
}

The top-level keys and the keys of docker are always present; a value that could not be discovered is null, so an unreachable daemon shows as "daemon": null rather than a missing key. Each entry in mounts carries host, container and ok, and the project entry also carries entries and expected. The exit status is the same as for the text report.

The text report ends with a schema line naming the version of the document it is written against, before the verdict, so a report pasted into an issue says which contract its lines belong to:

host      Linux (linux), perl 5.40.0, x86_64-linux
...
schema    version 2 (action-run schema check)
Result: ready

schema_version names the shape of the document, so an assertion can say which shape it was written for instead of failing on a key that moved. The schema is what action-run schema check prints, and the checked-in examples are validated against it in t/schema.t: t/fixtures/check.json for a passing report, t/fixtures/check-unready.json for one that found a mount mismatch, t/fixtures/check-unreachable.json for one whose Docker command could not be run and t/fixtures/check-uncounted.json for one whose probe could not count the project's entries. t/schema.t also fails when a property, an enum value or a null variant the schema names appears in none of them.

Before the document is printed it is checked against that schema, so a key that was added, removed or retyped without the schema changing exits 70 (see "EXIT STATUS") with a message naming the first mismatch, and nothing is written to standard output. A CI job therefore never receives a document its own contract would reject. The same applies to preflight --json, whose document is validated before it is returned.

preflight

action-run [options] preflight <workflow.yml> [project-directory]

Runs the checks a normal run makes before its first job and stops there: the environment probes, then the preparation of every image the selected jobs need. Nothing is started, so this is the cheap way to ask whether a workflow could start on this host.

The text output is the check report, the pull progress, and one line per image naming the job, service or action that needs it and whether it was pulled, already present, failed, skipped or deferred. A deferred image is one whose name only a step can supply: it is printed by its expression, with the reason it could not be resolved, and the step prepares it when the workflow runs. It ends with a schema line naming the version of the preflight document, so a pasted report says which contract it belongs to; the check report it embeds leaves its own version line out, because that is not the document printed. The exit status is 0 when the images are ready and 2 when they are not. With --json the document is the one check --json prints, with images listing every image in the order the run would need it and pull_concurrency reporting the number of pulls it was allowed at once:

{
  "schema_version": 3,
  "ready": true,
  "problems": 0,
  "pull_concurrency": 4,
  "host": { "os": "linux", "name": "Linux", "perl": "5.40.0", "archname": "x86_64-linux" },
  "docker": { "bin": "docker", "client": "29.9.1", "daemon": "29.9.1", "containers": "linux" },
  "image": "perl:5.38",
  "mounts": [ ... ],
  "issues": [],
  "images": [
    { "image": "perl:5.38", "source": "job 'test'", "status": "pulled" },
    { "image": "postgres:15", "source": "service 'db' of job 'test'", "status": "present" }
  ]
}

source says what needs the image: a job, a service container, or a docker:// action, naming the job as well. status is pulled when the run fetched the image, present when it was already there, failed when it could not be prepared, and skipped when the environment was not ready so nothing was attempted. A failed entry also carries error, and the same failure appears in issues with a remedy. ready is true only when the checks passed and every image is present or pulled; problems counts the check problems and the failed images together.

pull_concurrency is the number of pulls the preflight was allowed to run at once, which is --pull-concurrency, then ACTION_RUN_PULL_CONCURRENCY, then four, except that it is 1 on Windows, where images are pulled one at a time. It is the number the puller was given rather than how many pulls happened to overlap, so a CI job can assert that the setting reached the puller even when there was only one image to fetch.

An image built from a value no job has produced yet - another job's result, for instance - is deferred: image is null, status is deferred, and expression and error say what could not be resolved and why. A deferral is not a problem, so it leaves ready and problems alone: the job resolves and prepares that image itself, with its own values, before it starts. An image that only a step decides is deferred too, and its step prepares it before starting its container. This is what lets CI distinguish an image that was verified from one that could not be named yet.

The preflight document is versioned separately, because images can change without the check document changing. Its schema_version is App::Actions::Runner->preflight_schema_version, and the check document's is App::Actions::Check->json_schema_version. The schema is what action-run schema preflight prints, and t/fixtures/preflight.json, t/fixtures/preflight-unready.json, t/fixtures/preflight-unreachable.json and t/fixtures/preflight-not-ready.json are validated against it in t/schema.t, which also fails when a key, an enum value or a null variant the schema names appears in none of them.

Unlike a run, preflight keeps the images it pulled, so a run that follows starts warm. --pull and --no-pull apply as they do to a run, and --skip-check drops the probes while still preparing the images.

The schema of this document is what action-run schema preflight prints, so an assertion can be written against the contract itself rather than against a copy of it in a test directory.

schema

action-run [options] schema <check|preflight|cache|logs>

Prints the JSON schema of the named document and exits: check for the check --json document, preflight for the preflight --json one, cache for the cache --json listing and logs for the logs --json one. The output is a single JSON object, so a CI job can fetch the contract the document is written against rather than keeping its own copy:

action-run schema preflight > preflight-schema.json
action-run preflight --json .github/workflows/test.yml . \
    | check-jsonschema --schemafile preflight-schema.json -

It always prints JSON, so --json is not needed and is rejected as it is for a run. The schema and the version it declares come from the module that produces the document, so the two cannot disagree; version is the same number as the document's schema_version.

validate

action-run [options] validate <check|preflight|cache|logs> <document.json>

Checks a saved document against the schema that action-run schema prints, without Docker and without a workflow. The name is check, preflight, cache or logs, because a document says which shape its schema_version refers to but not which command produced it. A matching document prints one line and exits 0:

action-run validate preflight preflight.json
'preflight.json' is a valid preflight document (schema version 3)

A document that does not match prints the first mismatch, and how many others there are, and exits 70 (see "EXIT STATUS"); a file that is not JSON is rejected the same way, since the schema cannot judge what cannot be parsed. An unknown document name or a file that cannot be read is a usage error, and exits 2.

This is what lets a CI job check a result produced by another job or on another machine, where Docker and the workflow are not available:

action-run preflight --json .github/workflows/test.yml . > preflight.json
action-run validate preflight preflight.json

action-run cache --json > cache.json
action-run validate cache cache.json

action-run logs --json > logs.json
action-run validate logs logs.json

cache

action-run [options] cache [--prune] [--older-than DURATION] [--json]

Lists the images that runs have kept, with the size each one takes on disk and when a run last pulled it, and with --prune removes them:

Cache file: /home/me/.cache/action-run/cache.json

  IMAGE                                  SIZE  LAST USED          AGE
  alpine:3.20                         11.8 MB  2026-10-21 15:04  2 minutes ago
  perl:5.38                          157.2 MB  2026-10-21 15:04  2 minutes ago

2 images, 169.0 MB on disk.

AGE is measured from the time the listing was made, using the same generated_at minus last_used rule as the JSON document; an image with no recorded use shows unknown. A run records the images it pulled and kept, so the list names only those, not every image on the daemon. A name is removed from the record only when the image is gone or was removed by --prune, so an image deleted outside action-run is shown as missing rather than silently forgotten.

--older-than restricts the listing to images at least as old as its duration and makes --prune remove only those images. The duration is a number and a unit: 30s, 15m, 12h, 7d, 2w, 3mo or 1y, with the unit optionally spelled out. Months and years are the same 30- and 365-day spans AGE uses. An image with no recorded last use cannot be shown to be old enough, so it is left out of a filtered listing and left in the record by a filtered prune. An unreadable duration is a usage error, not a silent fall back to no filter.

Listing works with the daemon switched off: the names and dates are still printed and the sizes are reported as unknown. --prune needs the daemon, because it has to know what is still there: an image that has already gone is forgotten, one that cannot be removed stays in the record and makes the command exit 1, so a second attempt still sees it. An unreachable daemon exits 2 without touching the record.

With --json the listing is a single versioned object instead of the text report, for a CI job to assert on:

{
  "schema_version": 3,
  "generated_at": 1792540920,
  "cache_file": "/home/me/.cache/action-run/cache.json",
  "docker": { "bin": "docker", "reachable": true },
  "images": [
    { "image": "alpine:3.20", "pulls": 3, "last_used": 1792540800, "age": 120, "size": 12345678, "present": true },
    { "image": "perl:5.38", "pulls": 1, "last_used": 1792440000, "age": 100920, "size": null, "present": false }
  ],
  "totals": { "images": 2, "size": 12345678, "present": 1, "missing": 1 },
  "prune": null
}

With --older-than the images array and the totals count only the images the listing selected; an image with no recorded use is not selected. A prune document still lists the whole record that remains, because the filter chose what --prune attempted rather than what is left. size is the bytes the daemon reports, present whether the daemon still has the image, last_used the epoch second a run last used it, and age the seconds between generated_at and last_used. age is null when the record has no last use, and 0 when the last use is later than generated_at, so a clock that is ahead is not reported as a negative age. generated_at is the epoch second the document was made, so the age needs neither machine to agree on a clock. A daemon that cannot be reached leaves its reachable flag false and the sizes, the present values and the counts that depend on them null, so an assertion can tell "unknown" from "missing" rather than reading 0 as either. After --prune the prune key is an object rather than null, holding removed, freed, forgotten and the failed images that stayed in the record; the document is printed even when a removal failed, and the exit status is 1. A prune without a daemon still exits 2 without printing a document, because nothing was attempted.

The document is checked against its schema before it is printed, so a key that was added, removed or retyped without the schema changing exits 70 with a message naming the first mismatch (see "EXIT STATUS"), as it does for check --json and preflight --json. The schema is what action-run schema cache prints, and the checked-in examples validated against it in t/schema.t are t/fixtures/cache.json, t/fixtures/cache-empty.json, t/fixtures/cache-unreachable.json and t/fixtures/cache-prune.json. schema_version is App::Actions::Cache->json_schema_version.

logs

action-run [options] logs [--prune] [--older-than DURATION] [--json]

Lists the per-job logs that parallel runs have kept, with the size each one takes and how long ago the run wrote it, and with --prune removes them:

Logs directory: /home/me/.cache/action-run/logs

  RUN                               LOG                          SIZE  AGE
  20261021-121355-6ad8e4c30f83      job-1-test_n_one.log        1.2 KB  2 hours ago
  20261021-121355-6ad8e4c30f83      job-2-test_n_two.log        0.8 KB  2 hours ago
  20261020-091201-1f4b2c3d4e5f      job-1-test_n_one.log        1.0 KB  1 day ago

3 logs, 3.0 KB on disk.

A sequential run has no interleaved output to untangle and keeps no copies, so only parallel runs appear. The age is the file's own modification time, not the run's start, and is shown in the largest whole unit: minutes, hours, days, months or years. A log deleted outside action-run simply stops being listed.

--older-than restricts the listing to logs at least as old as its duration and makes --prune remove only those logs. The duration is a number and a unit: 30s, 15m, 12h, 7d, 2w, 3mo or 1y, with the unit optionally spelled out, using the same 30- and 365-day spans as AGE. A log is selected on its own modification time, so a run with only newer logs is left alone. --prune removes every selected log and the run directory once that is empty, which is what the listing's "Remove them with" line names. Only a file that matched the listing is unlinked, so a note left in a run directory survives; only a directory named the way action-run writes one - a timestamp followed by the run id - is considered, so a directory that ACTION_RUN_LOG_DIR shares with other things is not emptied. A removal that fails is reported, the rest of the prune carries on, and the command exits 1 so a second attempt still sees it. A filtered prune document lists the whole remaining record, because the filter chose what the prune attempted rather than what is left.

With --json the listing is a single versioned object instead of the text report, for a CI job to assert on:

{
  "schema_version": 3,
  "generated_at": 1792540920,
  "logs_directory": "/home/me/.cache/action-run/logs",
  "logs": [
    { "run": "20261021-121355-6ad8e4c30f83", "name": "job-1-test_n_one.log", "path": "/home/me/.cache/action-run/logs/20261021-121355-6ad8e4c30f83/job-1-test_n_one.log", "size": 1229, "mtime": 1792540800, "age": 120 },
    { "run": "20261021-121355-6ad8e4c30f83", "name": "job-2-test_n_two.log", "path": "/home/me/.cache/action-run/logs/20261021-121355-6ad8e4c30f83/job-2-test_n_two.log", "size": 819, "mtime": 1792540800, "age": 120 }
  ],
  "totals": { "runs": 1, "logs": 2, "size": 2048 },
  "prune": null
}

logs is sorted by run and then by file name. size is the bytes the log takes, generated_at the epoch second the document was made, mtime the epoch second the log was last written and age the seconds between them. age is generated_at minus mtime, so a caller can use it without subtracting from its own clock or parsing a phrase; it is 0 when the log's clock is ahead of generated_at. totals counts the runs, logs and bytes the listing found. With --older-than the logs array and totals count only the selected logs. After --prune the prune key is an object rather than null, holding removed, freed and the failed runs that stayed, each with its run name and error; the document is printed even when a removal failed, and the exit status is 1.

The document is checked against its schema before it is printed, so a key that was added, removed or retyped without the schema changing exits 70 with a message naming the first mismatch (see "EXIT STATUS"). The schema is what action-run schema logs prints, and the checked-in examples validated against it in t/schema.t are t/fixtures/logs.json, t/fixtures/logs-empty.json, t/fixtures/logs-prune.json and t/fixtures/logs-prune-failed.json. schema_version is App::Actions::Logs->json_schema_version.

OPTIONS

--job name, -j name

Only run jobs whose id or expanded display name matches name. Repeatable.

--image image

Fallback image for Linux jobs that do not declare container: and do not use shogo82148/actions-setup-perl. The default is ubuntu:latest. When a job uses runs-on: ubuntu-24.04, the default becomes ubuntu:24.04.

--perl-image template

Image template used for shogo82148/actions-setup-perl. The default is perl:%s, so perl-version: "5.38" uses perl:5.38.

--platform platform

Pass --platform to Docker, for example linux/amd64.

--docker program

The Docker program to run, for example /usr/local/bin/docker or a wrapper script. A wrapper written in Perl is run with the same Perl as action-run, so it works on Windows too. Default: $DOCKER, then docker.

--user user[:group]

Run the job and service containers as this user. By default containers run as their image's default user, usually root. If the user is neither you nor root, the job's runner folder (which holds its step scripts and secrets) must be open to every user of the computer, and a warning says so.

--env NAME=VALUE

Add or override an environment variable. Repeatable.

--secret NAME=VALUE

Make a secret available to ${{ secrets.NAME }}. Values are redacted from the Docker commands that action-run prints, and are not put on the Docker command line. Repeatable. Note that the value is on action-run's own command line; see "COMMON PITFALLS".

--pull, --no-pull

Force an image pull, or fail instead of pulling a missing image. With --pull each image is pulled once per run, even when several jobs, services or steps use it, so a matrix does not download the same base image again for every job.

--dry-run, -n

Print the Docker commands and run scripts without starting containers.

--explain

A dry run for containers rather than commands: it starts nothing and does not even need a Docker daemon, and prints one block per container the workflow would start, saying what each one would be told to run. A service has no command of its own in a workflow, so its block reports (the image's own) for the entrypoint and the command, or the entrypoint its options replace; that is what tells a service that stops on start-up from one that was given a bad command:

==> Service 'helper' of job 'test'
    container  action-run-0123abcd-2-svc-test-0-helper
    image      postgres:16
    entrypoint (the image's own)
    command    (the image's own)

It belongs to a run: check, preflight, schema, validate, cache and logs have no containers to explain and reject it.

--list

List the expanded jobs and exit.

--prune

With cache, remove the images a run kept and forget them; with logs, remove the kept job logs. With --older-than only those at least that old are removed. It is rejected by every other command.

--older-than duration

With cache or logs, show only the entries at least as old as duration, and make --prune remove only those. A duration is a number and a unit such as 30s, 15m, 12h, 7d, 2w, 3mo or 1y; the unit may be spelled out. An image with no recorded last use is not selected, because its age is unknown; a log always has the modification time it is filtered on. It is rejected by every other command.

--keep, -k

Keep containers, networks, images, and the temporary directory for debugging.

--keep-images, --no-keep-images

Keep the images the run pulled, so running the workflow again uses them instead of downloading them again. This is the default. --no-keep-images removes the images this run pulled once its containers and networks are gone, which is what a one-off image, or a machine that should not accumulate them, wants.

--strict-actions

Treat unsupported actions as failures instead of skipping them.

--json

With check, print a JSON object instead of the text report; with preflight, print the check report with the images it prepared added; with cache, print the image record as a versioned document instead of the text listing; with logs, print the kept logs as a versioned document instead of the table. It is rejected for a workflow run.

--skip-check

Skip the environment probes and the image preparation that otherwise run before any job starts, while still checking that the Docker command answers. For a run, images are then pulled when they are first used; preflight still prepares them, so it can be used to warm the images without probing. Use it when the probes do not apply, for example when the project directory lives on a network share that the daemon cannot mount but the workflow does not need it.

--parallel number, --parallel=off

How many jobs of a matrix may run at the same time. The default is the number of processors on this machine, so a matrix of independent jobs uses the machine rather than waiting on one container after another. --parallel=off runs them one at a time, which is what to use when the jobs compete for something a unit of concurrency cannot see, such as a fixed port or a service that is shared. The workflow's own strategy.max-parallel still caps the number. A run that keeps its containers (--keep), a dry run and an explain run always go one at a time, and so does Windows, where fork() is not reliable.

--pull-concurrency number

How many images may be pulled at the same time, when an image has to be fetched. The default is four, or the value of ACTION_RUN_PULL_CONCURRENCY if it is set; this option wins over both. 1 pulls them one after another. The value must be a positive integer. Windows always pulls one at a time, and preflight --json reports the number actually used in its pull_concurrency key.

--verbose, -v

Print Docker commands and extra diagnostics.

--quiet, -q

Suppress action-run progress output. Command output is still shown.

--help, -h

Show this help.

--version, -V

Show the version.

ENVIRONMENT

ACTION_RUN_LANG

The language of action-run's messages: en (the default), fr, de or zh. The system locale (LANG) is not used, so that scripts that read the messages on CI machines always see English unless they ask.

DOCKER

The Docker program to run, if --docker is not given. Default docker.

ACTION_RUN_PULL_CONCURRENCY

The default for --pull-concurrency: how many images are pulled at the same time. The option wins over it, and it wins over the built-in four.

ACTION_RUN_LOG_DIR

Where a parallel run keeps its per-job logs and where action-run logs reads them. The default is $XDG_CACHE_HOME/action-run/logs, or ~/.cache/action-run/logs when XDG_CACHE_HOME is not set, and %LOCALAPPDATA%\action-run\logs on Windows.

ACTION_RUN_CACHE_FILE

Where the image cache record lives. The default is $XDG_CACHE_HOME/action-run/cache.json, or ~/.cache/action-run/cache.json when XDG_CACHE_HOME is not set, and %LOCALAPPDATA%\action-run\cache.json on Windows. Set it to keep one project's images out of the machine-wide record.

EXAMPLES

action-run ~/src/njh/CGI-Info/.github/workflows/perl-ci.yml \
           ~/src/njh/CGI-Info

action-run --job test --image perl:5.40 \
           .github/workflows/test.yml .

action-run --dry-run --verbose \
           .github/workflows/alpine.yml .

action-run check ~/src/njh/CGI-Info

action-run check --json | jq -e '.ready and .docker.containers == "linux"'

action-run schema preflight | jq '.version'

action-run cache

action-run cache --json | jq -r '.totals.size'

action-run cache --prune --json | jq '.prune.failed'

action-run cache --older-than 30d

action-run cache --prune --older-than 7d

action-run schema cache | jq '.version'

action-run logs

action-run logs --older-than 30d

action-run logs --prune --older-than 7d

action-run logs --prune

action-run logs --json | jq '.totals.logs'

action-run schema logs | jq '.version'

EXIT STATUS

0 if every runnable job succeeded, 1 if a job failed, and 2 for a usage, Docker, or infrastructure error. check exits 0 when the environment is ready and 2 otherwise. cache --prune exits 1 if an image could not be removed, because the record still holds it, and cache --json keeps that status while still printing the record. logs --prune exits 1 when a kept log could not be removed, and names the run it left behind; logs --json keeps that status while still printing the document.

70 means a document did not match the schema it was checked against: either check --json, preflight --json, cache --json or logs --json refused to print one of its own (so nothing was printed), or validate was given a saved document that does not match, including one that is not JSON at all. It is EX_SOFTWARE from sysexits.h and is deliberately separate from 2: a document is at fault, not the host a CI job would otherwise repair.

LIMITATIONS

Values given with --secret and --env are on action-run's own command line, which other users of the computer can read; only the Docker command lines it starts keep them hidden.

The per-job logs of a parallel run are kept until action-run logs --prune removes them, or action-run logs --prune --older-than DURATION removes the old ones; nothing expires them on its own.

Only Linux jobs are supported; macOS and Windows matrix entries are skipped. Local JavaScript and composite actions are run (JavaScript ones need node in the job image); remote actions are not fetched. actions/checkout and shogo82148/actions-setup-perl become no-ops because the project is already mounted and the container image is chosen from the requested Perl version. Cache, artifact, and Codecov actions are also no-ops. Unknown actions are skipped with a warning unless --strict-actions is used.

COMMON PITFALLS

  • Run action-run check first. Most failures before the first job are a Docker problem (not running, Windows containers, a drive not shared), and check says how to fix each one.

  • --secret values are visible in ps. Every user of the computer can read action-run's own command line while it runs. On a shared computer, do not pass real secrets this way; use a test value, or run on a computer only you use.

  • Give the project directory when the workflow is elsewhere. If the workflow file is not inside the project's .github/workflows, the project directory must be the second argument.

  • Jobs for macOS and Windows are skipped, not failed. The exit status can be 0 even though some jobs did not run. The summary lists them as skipped.

  • Remote actions do not run. Only ./local actions run. Use --strict-actions to make a skipped action fail the job.

  • Images are kept after the run. Use action-run cache --prune (or --no-keep-images) to free the disk space.

  • Parallel output goes to logs. With --parallel above 1, each job's lines are shown with its name in front, and the full logs are kept; see action-run logs.

  • --older-than needs a unit. 30d or 2 weeks, not 30.

  • Exit status 70 is a bug. It means a JSON document did not match its own schema. Please report it.

  • Only run workflows you trust. A workflow's container.volumes and options can mount any folder of your computer into a container.

ENCODING

Workflow files are read as UTF-8; their text may contain any Unicode characters, including emoji. File and folder names on the command line are used as bytes. Values given with --env and --secret are passed to the containers as the bytes you typed (normally UTF-8). What the containers print is shown unchanged. Messages are English unless ACTION_RUN_LANG chooses another language; set your terminal to UTF-8 for French, German or Chinese. JSON documents (--json) are UTF-8.

AUTHOR

Nigel Horne, njh@nigelhorne.com

STATE DIAGRAM

What happens when you run action-run WORKFLOW [DIR] (the other commands are shown on the left):

 action-run [options] ARGS
        |
        v
+-------------------+  bad option or value  -> message; exit 2
| PARSE OPTIONS     |  --help / --version   -> print; exit 0
+-------------------+
        |
        +-- check [DIR] ----------> App::Actions::Check: report; exit 0 / 2
        +-- schema KIND ----------> print the JSON schema; exit 0
        +-- validate KIND FILE ---> check a saved document; exit 0 / 70
        +-- cache [--prune] ------> App::Actions::Cache; exit 0 / 1 (/ 70)
        +-- logs [--prune] -------> App::Actions::Logs;  exit 0 / 1 (/ 70)
        +-- preflight WF [DIR] ---> checks and pulls only; exit 0 / 2 (/ 70)
        +-- --list WF ------------> one line per job; exit 0
        |
        v  (run)
+-------------------+  workflow missing or invalid -> message; exit 2
| LOAD WORKFLOW     |
+-------------------+
        |
        v
+-------------------+  not ready (unless --skip-check) -> report; exit 2
| CHECK DOCKER      |
+-------------------+
        |
        v
+-------------------+  an image cannot be pulled -> message; exit 2
| PULL IMAGES       |
+-------------------+
        |
        v
+-------------------+  for each job: network, services, container,
| RUN JOBS          |  steps, clean-up (see App::Actions::Runner)
+-------------------+  Ctrl-C: clean up, then exit 2
        |
        v
+-------------------+
| SUMMARY           |  exit 0 = all passed, 1 = a job failed
+-------------------+

LICENSE AND COPYRIGHT

Copyright 2026 Nigel Horne.

This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.