NAME

App::Actions::Runner - plan and run a GitHub Actions workflow in Docker

SYNOPSIS

use App::Actions::Runner;
use App::Actions::Workflow;

my $runner = App::Actions::Runner->new(
	workflow    => App::Actions::Workflow->load("$project/.github/workflows/ci.yml"),
	project_dir => $project,
	image       => 'ubuntu:latest',
);
my $exit = $runner->run;

Most callers use App::Actions, which builds the runner for them.

Run only some jobs, without starting anything (a dry run):

my $runner = App::Actions::Runner->new(
	workflow    => $workflow,
	project_dir => $project,
	jobs        => [ 'lint', 'test' ],
	dry_run     => 1,
);
$runner->run;    # prints the Docker commands it would run

Prepare everything, then report as data:

my $result = App::Actions::Runner->new(workflow => $workflow, project_dir => $project)
	->preflight(json => 1);
print "ready\n" if $result->{exit} == 0;

Test without Docker, with an object that has the App::Actions::Docker interface:

my $runner = App::Actions::Runner->new(
	workflow    => $workflow,
	project_dir => $project,
	docker      => My::FakeDocker->new,
);

DESCRIPTION

In short: the runner is the part of App::Actions that decides what to run, in what order, and then asks its helper modules to do it.

The runner is the planner of a run. It expands the workflow's jobs, selects the ones asked for, checks the host, orders the jobs by their needs, decides each job's if, and hands the work to its parts:

Each job gets its own Docker network, its own service containers, and a long-lived job container. Steps are executed with docker exec, so filesystem changes persist between steps while each step gets a fresh shell, just as on GitHub. Containers and networks are removed when the job or run finishes. The images the run pulled are kept by default, because they are the next run's cache; keep_images set to a false value removes the ones this run pulled. Use keep for debugging, which keeps the containers and networks as well.

The jobs of one matrix run beside each other, up to parallel at a time, when the host can fork and the run is not one that keeps its containers. parallel defaults to 1 in the library; action-run sets it to the number of processors unless --parallel says otherwise, and the workflow's strategy.max-parallel still caps it.

METHODS

new

Creates a runner for one workflow and project.

ARGUMENTS

  • workflow - an App::Actions::Workflow.

  • project_dir - the project directory; it must exist.

  • The run options of "option_rules": jobs, image, perl_image, platform, user, env, secrets, dry_run, explain, verbose, quiet, keep, keep_images, log_dir, cache_file, pull, no_pull, strict_actions, skip_check, pull_concurrency, parallel, docker, docker_bin. They are described in "new" in App::Actions.

  • logger - a Log::Abstraction object (anything with warn, error and notice methods) that receives the run's warnings and errors. Unlike "new" in App::Actions, this takes only an object. It defaults to "default_logger" in App::Actions::Runner::Support, which prints them to standard error. It is handed on to the parts of the runner, so every part logs to the same place.

RETURNS

An App::Actions::Runner object.

SIDE EFFECTS

Reads ACTION_RUN_PULL_CONCURRENCY when pull_concurrency is not given.

EXAMPLE

my $runner = App::Actions::Runner->new(
	workflow => $workflow,
	project_dir => '.',
	jobs => [ 'test' ],
	parallel => 4,
	keep_images => 0,
);

API SPECIFICATION

INPUT

{
	workflow    => { type => 'object', can => 'expand_jobs' },
	project_dir => { type => 'string' },
	%{ App::Actions::Runner->option_rules },
}

OUTPUT

{ type => 'object', isa => 'App::Actions::Runner' }

MESSAGES

+-----------------------------+------------------------------+-----------------------------+
| Message                     | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| A workflow is required      | workflow was not given       | Load one with               |
|                             |                              | App::Actions::Workflow      |
| A project directory is      | project_dir was not given    | Pass the repository's path  |
| required                    |                              |                             |
| Project directory 'X' does  | The path is not a directory  | Check the path              |
| not exist                   |                              |                             |
| pull concurrency must be a  | pull_concurrency or          | Use a whole number above 0  |
| positive integer, not 'X'   | ACTION_RUN_PULL_CONCURRENCY  |                             |
|                             | is not one                   |                             |
| --pull and --no-pull cannot | Both were given              | Choose one                  |
| be used together            |                              |                             |
| Invalid arguments to ...    | An unknown option, or one of | See App::Actions/new        |
|                             | the wrong type               |                             |
+-----------------------------+------------------------------+-----------------------------+

PSEUDOCODE

validate the arguments against the option rules
require the workflow and an existing project directory
work out the pull concurrency and the parallelism
build the Docker object unless one was given
use the default logger (standard error) unless one was given
refuse --pull with --no-pull
build the image preparer, the container manager and the reporter,
    each with the logger

option_rules

my $rules = App::Actions::Runner->option_rules;

Returns a copy of the Params::Validate::Strict rule of every run option, by name. App::Actions validates its own arguments with it, so the two accept exactly the same options.

EXAMPLE

my @options = sort keys %{ App::Actions::Runner->option_rules };

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'hashref' }

MESSAGES

None.

json_schema

my $schema = App::Actions::Runner->json_schema;

Returns the JSON schema of the preflight document as a hash, the same document action-run schema preflight prints. It is kept here so that the contract ships with the module rather than only with the tests.

EXAMPLE

print JSON::PP->new->canonical->pretty->encode(App::Actions::Runner->json_schema);

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'hashref' }

MESSAGES

None.

preflight_schema_version

my $version = App::Actions::Runner->preflight_schema_version;

Returns the version of the preflight JSON document, which is also its schema_version key. The checked-in example is validated against the schema in t/schema.t.

EXAMPLE

die 'too old' if App::Actions::Runner->preflight_schema_version < 3;

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', min => 1 }

MESSAGES

None.

workflow

Returns the App::Actions::Workflow the runner was given.

EXAMPLE

print $runner->workflow->name, "\n";

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'object', isa => 'App::Actions::Workflow' }

MESSAGES

None.

list_jobs

Prints the selected job instances, one per line as base_id TAB display name, and returns 0.

EXAMPLE

$runner->list_jobs;
# test	test (perl=5.38)
# test	test (perl=5.40)

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', memberof => [ 0 ] }

MESSAGES

+-----------------------------+------------------------------+-----------------------------+
| Message                     | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| No job matched 'X'          | A jobs filter names no job   | Use an id or a display name |
|                             |                              | the listing shows           |
+-----------------------------+------------------------------+-----------------------------+

run

Runs every selected job and returns an exit status: 0 for success, 1 for a workflow failure, 2 for a usage or Docker error.

EXAMPLE

exit App::Actions::Runner->new(workflow => $workflow, project_dir => '.')->run;

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', memberof => [ 0, 1, 2 ] }

MESSAGES

Everything the run fails with is printed as action-run: MESSAGE on standard error, and the run exits 2. The commonest:

+-----------------------------+------------------------------+-----------------------------+
| Message                     | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| the environment is not      | The host check failed        | Fix what the report asks,   |
| ready; ...                  |                              | or pass --skip-check        |
| cannot prepare the image    | An image could not be pulled | Check the name and registry |
| 'X', needed by S: E         |                              |                             |
| Dependency cycle involving  | Jobs need each other         | Break the cycle in needs    |
| job 'X'                     |                              |                             |
| Interrupted / Terminated    | SIGINT or SIGTERM            | None                        |
+-----------------------------+------------------------------+-----------------------------+

PSEUDOCODE

turn SIGINT and SIGTERM into errors, so the cleanup still runs
expand and select the jobs
unless it is a dry run, check the host and prepare every image
make the temporary directory and the base environment
run the jobs, group by group in needs order
print the summary
on an error, print it and exit 2
clean up: containers, networks, images not kept, the temporary directory

preflight

my $result = $runner->preflight(json => 1);

Makes the same checks and image preparations as a run, without starting a container, so a caller can report the outcome as data rather than as prose.

ARGUMENTS

  • json - optional; print nothing (the checks write into a buffer that is discarded and the pull output is dropped), and return the document action-run preflight --json prints, validated against "json_schema".

  • schema_line - optional, default true; end the text report with the version of the document's schema. A caller that prints its own report, such as the smoke test, turns it off.

RETURNS

{ exit => 0 | 2, summary => \%document, error => $message_or_undef }. The document is the App::Actions::Check report with images added and pull_concurrency the number of pulls allowed at once (1 on Windows). Its schema_version is "preflight_schema_version". With json, ready is a JSON boolean.

SIDE EFFECTS

Probes the host, pulls images and records them in the image cache; prints a report unless json is set.

EXAMPLE

my $result = $runner->preflight;
print STDERR "action-run: $result->{error}\n" if defined $result->{error};
exit $result->{exit};

API SPECIFICATION

INPUT

{
	json        => { type => 'boolean', optional => 1 },
	schema_line => { type => 'boolean', optional => 1 },
}

OUTPUT

{
	type => 'hashref',
	schema => {
		exit    => { type => 'integer', memberof => [ 0, 2 ] },
		summary => { type => 'hashref' },
		error   => { type => 'string', optional => 1 },
	},
}

MESSAGES

+-----------------------------+------------------------------+-----------------------------+
| Message (in error)          | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| the environment is not      | A host check failed          | Fix what the report asks    |
| ready; ...                  |                              |                             |
| cannot prepare the image    | An image could not be pulled | Check the name and registry |
| 'X', needed by S: E         |                              |                             |
| the action-run preflight    | The document does not match  | A bug in this distribution; |
| --json document does not    | its schema; raised as an     | report it                   |
| match ...json_schema: ...   | App::Actions::Schema::Invalid|                             |
+-----------------------------+------------------------------+-----------------------------+

PSEUDOCODE

expand and select the jobs
with skip_check: ask only whether Docker answers; if not, return a
	document that says so
otherwise run the host check, silently for a JSON caller
if the check failed, list the images the run would need as skipped
otherwise prepare them and add an issue for each that failed
print the image lines and the schema line, for a text caller
record what was pulled in the image cache
return the exit status, the document and the first error

LIMITATIONS

  • Only Linux runs-on runners are emulated; macOS and Windows jobs are skipped.

  • Remote actions are not fetched, and local docker actions are not built. Checkout, setup-perl, cache, artifact and Codecov actions are no-ops; other actions are skipped with a warning unless strict_actions is set. Local composite and JavaScript actions are run; JavaScript actions need node in the job image.

  • Job outputs, needs.*.outputs, timeout-minutes and hashFiles() are not implemented.

  • The runner object is not reusable: a second run after the first has cleaned up starts with the first one's state. Build a new runner, as App::Actions does for every call.

  • The run's state is still shared between the planner and its parts through the option values they are built with; a part is not given the whole runner, but a change of option after construction is not seen by the parts.

  • A workflow is trusted as GitHub trusts it. Its container.volumes and container.options can mount any host path the Docker daemon can reach, and its steps run with the daemon's privileges. Run only workflows from repositories you trust.

  • With user set to anyone but yourself or root, the runner directory is made writable by every user of the host (sticky, so nobody can replace another's files), and the step scripts in it, secrets included, are readable by them; a warning says so. With rootless Docker, a user that is your own uid inside the container maps to another uid on the host, and the job may then be unable to write its files.

  • Taint mode (-T) is not supported. action-run runs with its user's own privileges, so taint checks would protect nothing that user could not already do; the environment, directory listings and PATH lookups it relies on would all fail them.

COMMON PITFALLS

  • Make a new runner for each run. A runner is not reusable: a second "run" starts with the state the first left behind.

  • project_dir must exist. It is mounted into every job container.

  • Options are fixed when the runner is made. The parts copy them, so changing an option afterwards has no effect.

  • run reports failures by status, not by exception. Errors of the run are printed as action-run: MESSAGE and give status 2.

  • Ctrl-C still cleans up. SIGINT and SIGTERM stop the run, but the containers and networks are removed first. Press Ctrl-C again only if the clean-up itself hangs.

  • keep keeps everything. With keep, containers and networks are left running for you to inspect; remove them yourself afterwards (their names start with action-run-).

  • parallel is ignored with keep, on Windows, and where fork does not work; jobs then run one at a time.

  • user other than yourself or root opens the runner folder to all users. See "LIMITATIONS".

ENCODING

Text from the workflow (commands, names, environment values, secrets) is handled as Perl character strings and passed to Docker as UTF-8 bytes, so non-ASCII characters and emoji work in step commands and values. Step scripts are written as UTF-8 with LF line endings. Paths (the project folder, the temporary folder) are used as bytes, as your system stores them. Output from the containers is passed to your terminal as bytes, unchanged. Messages from the runner itself are in the language of App::Actions::I18N; non-English messages contain non-ASCII characters.

AUTHOR

Nigel Horne, njh@nigelhorne.com

FORMAL SPECIFICATION

This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above. It is for readers who want to check exactly what a routine promises.

How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. ΞState means the routine changes nothing, and ΔState means it can change the state. The lines below the rule are the conditions that are always true when the routine returns.

new

┌─ NewRunner ───────────────────────────────────────
│ workflow?    : WORKFLOW
│ projectDir?  : PATH
│ options?     : OPTION ⇸ VALUE
│ r!           : Runner
├───────────────────────────────────────────────────
│ dom options? ⊆ dom optionRules
│ isDirectory(projectDir?)
│ ¬ (options?(pull) ∧ options?(no_pull))
│ r!.pullConcurrency =
│     first(options?(pull_concurrency), env(ACTION_RUN_PULL_CONCURRENCY), 4) ∈ ℕ₁
│ r!.parallel = (if options?(parallel) ∈ ℕ₁ then options?(parallel) else 1)
│ r!.dryRun = options?(dry_run) ∨ options?(explain)
│ r!.keepImages = (if options?(keep_images) = ⊥ then true else options?(keep_images))
│ r!.logger = (if options?(logger) = ⊥ then stderrLogger else options?(logger))
│ ∀ part : {images, containers, steps, parallel, report} • part.logger = r!.logger
└───────────────────────────────────────────────────

option_rules

┌─ OptionRules ─────────────────────────────────────
│ rules! : OPTION ⇸ RULE
├───────────────────────────────────────────────────
│ rules! = optionRules
└───────────────────────────────────────────────────

json_schema

┌─ JsonSchema ──────────────────────────────────────
│ schema! : JSON
├───────────────────────────────────────────────────
│ schema! = decode(JSON_SCHEMA) ∧ schema!.version = PREFLIGHT_SCHEMA_VERSION
└───────────────────────────────────────────────────

preflight_schema_version

┌─ PreflightSchemaVersion ──────────────────────────
│ v! : ℕ₁
├───────────────────────────────────────────────────
│ v! = PREFLIGHT_SCHEMA_VERSION
└───────────────────────────────────────────────────

workflow

┌─ Workflow ────────────────────────────────────────
│ ΞRunner
│ w! : WORKFLOW
├───────────────────────────────────────────────────
│ w! = workflow
└───────────────────────────────────────────────────

list_jobs

┌─ ListJobs ────────────────────────────────────────
│ ΞRunner
│ out! : seq LINE
│ exit! : ℤ
├───────────────────────────────────────────────────
│ out! = ⟨ j : selected(expand(workflow)) • j.base_id ⁀ TAB ⁀ redact(j.display_name) ⟩
│ exit! = 0
└───────────────────────────────────────────────────

run

┌─ Run ─────────────────────────────────────────────
│ ΔRunner
│ exit! : {0, 1, 2}
├───────────────────────────────────────────────────
│ raised ⇒ exit! = 2
│ ¬ raised ∧ report.failed' ⇒ exit! = 1
│ ¬ raised ∧ ¬ report.failed' ⇒ exit! = 0
│ cleanedUp'
└───────────────────────────────────────────────────

preflight

┌─ Preflight ───────────────────────────────────────
│ ΔRunner
│ json? : 𝔹
│ r!    : RESULT
├───────────────────────────────────────────────────
│ r!.summary.ready ⇔ checksPassed ∧ (∀ i : r!.summary.images • i.status ≠ failed)
│ r!.exit = (if r!.summary.ready then 0 else 2)
│ json? ⇒ valid(r!.summary, JsonSchema)
│ ¬ checksPassed ⇒ ∀ i : r!.summary.images • i.status = skipped
└───────────────────────────────────────────────────

STATE DIAGRAM

The runner is used once. These are the states of one "run" ("preflight" stops after PREPARING IMAGES; a dry run prints the Docker commands instead of running them):

new(workflow, project_dir, %options)
      |   options checked, parts built from copies of the options
      v
+-----------+  run / preflight
|  CREATED  |-----------------+
+-----------+                 |
                              v
                     +-----------------+  SIGINT / SIGTERM at any later
                     | SELECTING JOBS  |  point -> error "Interrupted" ->
                     | expand matrix;  |  CLEANING UP; status 2
                     | jobs filter;    |
                     | non-Linux: skip |
                     +-----------------+
                              |
                              v
                     +-----------------+  daemon / Linux / mount problem
                     | CHECKING HOST   |  -> report printed -> FAILED (2)
                     | (skip_check:    |
                     |  skipped)       |
                     +-----------------+
                              |
                              v
                     +-----------------+  a pull fails -> FAILED (2)
                     | PREPARING IMAGES|  preflight: return here
                     | (Images part)   |  { exit, summary, error }
                     +-----------------+
                              |
                              v
                     +-----------------+  needs cycle -> FAILED (2)
                     | ORDERING JOBS   |
                     | by needs        |
                     +-----------------+
                              |
                              v
         +---------> +-----------------+
         |           | NEXT GROUP      |  jobs whose needs are done
         |           +-----------------+
         |              |           |
         |   if: false, |           | one job, or a matrix with
         |   or a need  |           | parallel = 1
         |   failed:    |           v
         |   SKIPPED    |   +----------------+    a matrix with parallel > 1:
         |              |   | RUNNING JOB    |    Parallel part forks one
         |              |   | Containers:    |    child per job, up to
         |              |   |   network,     |    "parallel" at once; each
         |              |   |   services,    |    child does RUNNING JOB;
         |              |   |   job container|    output goes to log files
         |              |   | Steps: each    |
         |              |   |   step         |
         |              |   | Containers:    |
         |              |   |   remove       |
         |              |   +----------------+
         |              |           |
         |              +-----+-----+  job passed / failed / skipped
         |                    |
         +---- more groups ---+
                              | no more
                              v
                     +-----------------+
                     | REPORTING       |  summary table (Report part);
                     +-----------------+  logs kept for parallel jobs
                              |
                              v
                     +-----------------+  containers, networks; images
                     | CLEANING UP     |  this run pulled (unless
                     |                 |  keep_images); temporary folder
                     +-----------------+  (all skipped with keep)
                              |
                              v
            status 0 (all passed), 1 (a job failed), 2 (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.