NAME

App::Actions - run GitHub Actions workflows locally

VERSION

0.001.0

SYNOPSIS

Run every job of a workflow (this is what action-run run does):

use App::Actions;

my $actions = App::Actions->new(
	workflow_file => "$ENV{HOME}/src/CGI-Info/.github/workflows/perl-ci.yml",
	project_dir   => "$ENV{HOME}/src/CGI-Info",
);

exit $actions->run();    # 0 = passed, 1 = a job failed, 2 = could not run

Run one job, with a secret, and show the Docker commands:

my $actions = App::Actions->new(
	workflow_file => '.github/workflows/ci.yml',
	jobs          => [ 'test' ],
	secrets       => { CODECOV_TOKEN => $ENV{CODECOV_TOKEN} },
	verbose       => 1,
);
exit $actions->run;

See what would run, without running it:

App::Actions->new(workflow_file => '.github/workflows/ci.yml')->list_jobs;
exit App::Actions->new(workflow_file => '.github/workflows/ci.yml', dry_run => 1)->run;

Check Docker and download the images first, for a CI job:

use JSON::PP;

my $result = App::Actions->new(workflow_file => '.github/workflows/ci.yml')->preflight(json => 1);
print JSON::PP->new->canonical->encode($result->{summary}), "\n";
exit $result->{exit};

Run the jobs of a matrix four at a time, and keep their logs:

App::Actions->new(workflow_file => '.github/workflows/ci.yml', parallel => 4)->run;
# logs: see "action-run logs"

DESCRIPTION

In short: this module runs a GitHub Actions workflow on your own computer, in Docker containers, so you can test and debug it without pushing to GitHub. Most people use it through the action-run command; see action-run.

How it works, in brief:

1. It reads the workflow file (App::Actions::Workflow) and expands each job's matrix.
2. It checks that Docker works and can see your project (as action-run check does).
3. It downloads the images the jobs need, several at a time.
4. For each job, it makes a private Docker network, starts the job's service containers (for example a database), and starts one long-lived container for the job, with your project mounted in it.
5. It runs each step in that container with docker exec. Files and installed modules stay between steps, as on GitHub.
6. It removes the job's containers and network, and reports the result.

The rest of this section gives the details.

App::Actions runs a GitHub Actions workflow on a local machine using Docker containers, allowing troublesome issues to be more easily debugged. It understands the parts of a workflow that are useful for local testing: jobs, matrix expansion, container images, service containers, environment files, working directories, shells, and if conditions.

Each job gets a private Docker network, its own service containers, and a long-lived job container. Steps are executed in that container with docker exec, so files and installed modules persist between steps. A service must stay up: one that stops, or that a restart policy keeps bringing back, stops the run with the state and restart count the daemon reports and the last lines the container printed. A container the daemon refuses to start is reported the same way: its message is quoted and the image, entrypoint or published port it names is called out, because there is nothing of the container's own to quote. When a job finishes, its containers and network are removed; the images the run pulled are kept, as the next run's cache, unless keep_images is set to a false value. The GITHUB_ENV, GITHUB_PATH, GITHUB_OUTPUT, GITHUB_STATE, and GITHUB_STEP_SUMMARY files written by steps are honoured. If an image has no sudo and the container is running as root, a small sudo shim is placed on PATH so that steps such as sudo apt update work locally.

The host may run UNIX or Windows; only the containers are Linux. Command output is captured without fork or a piped open, and paths, volumes and permissions are handled in a platform-neutral way, so the same code and test suite run on both. Step scripts, the sudo and apt shims, and event.json are written with LF line endings, which is what the container's shell expects. Arguments are quoted for the Windows command line by App::Actions::Process itself, because Perl's own Windows quoting gives up on any argument containing a double quote and the C runtime then splits it at its spaces.

Run action-run check to diagnose the host before running a workflow. It reports the platform, the Docker client and daemon versions, whether the daemon runs Linux containers, and whether Docker can bind-mount a temporary directory and the project directory. Each problem it finds is followed by the step that fixes it, such as starting Docker Desktop, switching it to Linux containers, or sharing the drive that holds the project. See action-run for the details.

Errors raised by this module, App::Actions::Workflow and App::Actions::Action come from the catalogue in App::Actions::I18N. They are in English unless ACTION_RUN_LANG names another catalogue (fr, de or zh).

METHODS

new

Creates the object that runs one workflow.

ARGUMENTS

  • workflow_file - required; the path of the workflow.

  • project_dir - the repository the workflow runs against. It may be omitted when the workflow is inside a repository's .github/workflows directory; the repository root is then used.

  • Run options: jobs, image, perl_image, platform, user, env, secrets, dry_run, explain, verbose, quiet, keep, keep_images, pull, no_pull, strict_actions, skip_check, pull_concurrency, parallel, log_dir, cache_file, docker, and docker_bin. Any other key is an error.

parallel says how many jobs of one matrix may run at the same time; it is 1 unless it is set, and action-run sets it to the number of processors unless --parallel says otherwise. pull_concurrency says how many images may be pulled at the same time; it defaults to ACTION_RUN_PULL_CONCURRENCY from the environment, and to four when that is not set or empty. It must be a positive integer. keep_images defaults to true: the images a run pulls stay behind as the next run's cache, and a false value removes the ones the run itself pulled when it finishes. The kept images are recorded by App::Actions::Cache, which is what action-run cache lists and action-run cache --prune removes; the record lives in the user's cache directory, and cache_file or ACTION_RUN_CACHE_FILE moves it. log_dir says where the per-job logs of a parallel run are kept; it defaults to ACTION_RUN_LOG_DIR, then to action-run/logs under that same cache directory. action-run logs reads that directory, with the sizes and ages of the logs, action-run logs --json prints them, with the clock their ages are measured from, as a versioned document, and action-run logs --prune removes them. --older-than filters that listing, and with --prune removes only logs at least that old; a log is selected on its own modification time, so newer logs in the same run stay.

explain is a dry run whose output is the containers rather than the Docker commands: nothing is started and no daemon is contacted, and one block per container says what it would be told to run, which is how a service that stops on start-up can be diagnosed without running it.

Before any job starts, the environment is probed exactly as action-run check does: the daemon must be reachable and run Linux containers, and the project directory must be readable through a bind mount. The probe uses the image the first runnable job will use, so a working setup needs no extra pull. It says nothing when it passes and prints the full report, with remedies, when it does not, in which case no container is started. skip_check starts the workflow anyway, and is what action-run --skip-check sets.

The images the run needs are then prepared before the first container: job images, service images and docker:// action images, with matrix values resolved. A missing image is pulled at that point, so a mistyped image is reported before any job has started rather than halfway through one. Local composite actions are read up front for the same reason, including actions used inside other actions, so an image named within one is known before its first step; only an image whose name the run alone can resolve is left until it is used. Presence is checked first, then the missing images are pulled pull_concurrency at a time (four by default), so a wide matrix does not sit through one download after another; a failure is still reported against the image and the job, service or action that needed it, whichever pull finished first.

pull refreshes the images even when they are present locally, and each image is pulled once per run, however many jobs, services or steps use it: the check a job makes of its own image reuses what the run has already fetched.

docker and docker_bin name the Docker command. It may also be a wrapper script; a wrapper written in Perl is run with the current Perl interpreter, so it works on Windows too, where the operating system cannot start a shebang script directly. docker may instead be an object with the App::Actions::Docker interface, which is how the tests run without one.

RETURNS

An App::Actions object.

SIDE EFFECTS

Reads and parses the workflow file.

EXAMPLE

# The repository root is found from the workflow's path.
my $actions = App::Actions->new(
	workflow_file => 'project/.github/workflows/ci.yml',
	jobs          => [ 'test' ],
	dry_run       => 1,
);

API SPECIFICATION

INPUT

{
	workflow_file    => { type => 'string', min => 1 },
	project_dir      => { type => 'string', optional => 1 },
	jobs             => { type => 'arrayref', optional => 1 },
	env              => { type => 'hashref', optional => 1 },
	secrets          => { type => 'hashref', optional => 1 },
	docker           => { type => 'object', optional => 1 },
	image            => { type => 'string', optional => 1 },
	perl_image       => { type => 'string', optional => 1 },
	platform         => { type => 'string', optional => 1 },
	user             => { type => 'string', optional => 1 },
	log_dir          => { type => 'string', optional => 1 },
	cache_file       => { type => 'string', optional => 1 },
	docker_bin       => { type => 'string', optional => 1 },
	pull_concurrency => { type => 'string', optional => 1 },
	parallel         => { type => 'string', optional => 1 },
	dry_run          => { type => 'string', optional => 1 },
	explain          => { type => 'string', optional => 1 },
	verbose          => { type => 'string', optional => 1 },
	quiet            => { type => 'string', optional => 1 },
	keep             => { type => 'string', optional => 1 },
	keep_images      => { type => 'string', optional => 1 },
	pull             => { type => 'string', optional => 1 },
	no_pull          => { type => 'string', optional => 1 },
	strict_actions   => { type => 'string', optional => 1 },
	skip_check       => { type => 'string', optional => 1 },
}

OUTPUT

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

MESSAGES

+-----------------------------------+------------------------------+-------------------------------+
| Message                           | Meaning                      | Resolution                    |
+-----------------------------------+------------------------------+-------------------------------+
| workflow_file is required         | No workflow was named        | Pass workflow_file            |
| Workflow file 'F' does not exist  | No file at the path          | Check the path                |
| project_dir is required when the  | The repository root cannot   | Pass project_dir              |
| workflow is not inside            | be inferred                  |                               |
| .github/workflows                 |                              |                               |
| Project directory 'D' does not    | No directory at the path     | Check the path                |
| exist                             |                              |                               |
| Invalid arguments to              | An unknown option, or one of | Use the documented options    |
| App::Actions::new: REASON         | the wrong type               |                               |
+-----------------------------------+------------------------------+-------------------------------+

The messages of "load" in App::Actions::Workflow apply to the workflow file.

PSEUDOCODE

validate the arguments, refusing unknown options
refuse a missing workflow_file, then one that does not exist
take project_dir as given, or the repository root that holds .github/workflows
refuse a project directory that does not exist
load the workflow
return the object holding the paths, the workflow and the options

workflow, workflow_file, project_dir

my $workflow = $actions->workflow;         # the App::Actions::Workflow
my $file     = $actions->workflow_file;    # its absolute path
my $root     = $actions->project_dir;      # the absolute project directory

Read-only accessors.

API SPECIFICATION

INPUT

{}

OUTPUT

workflow:                   { type => 'object', isa => 'App::Actions::Workflow' }
workflow_file, project_dir: { type => 'string' }

MESSAGES

None.

EXAMPLE

printf "%s from %s\n", $actions->workflow->name, $actions->project_dir;

run

Runs the selected jobs.

ARGUMENTS

Any of the run options of "new", which override the object's for this run only.

RETURNS

0 for success, 1 when a job fails, and 2 for a usage, Docker, or other infrastructure error.

SIDE EFFECTS

Starts and removes Docker containers and networks, pulls images, writes the image cache record and, for a parallel run, the job logs; prints the run's progress unless quiet.

EXAMPLE

my $exit = $actions->run(jobs => [ 'lint' ], verbose => 1);
exit $exit;

API SPECIFICATION

INPUT

The option rules of "new", all optional, without workflow_file and project_dir.

OUTPUT

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

MESSAGES

+--------------------------+-------------------------------+--------------------------------+
| Message                  | Meaning                       | Resolution                     |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to     | An unknown or mistyped        | Use the options of new         |
| App::Actions::run: R     | override                      |                                |
+--------------------------+-------------------------------+--------------------------------+

Failures of the run itself are printed by App::Actions::Runner and reported by the exit code rather than raised.

preflight

Makes the same checks as a run before its first job, then prepares the images the selected jobs need, without starting anything.

ARGUMENTS

  • json - optional; true to print nothing and return the document action-run preflight --json prints.

  • schema_line - optional; passed to the runner.

  • Any run option of "new", overriding the object's.

RETURNS

A hash of exit (0 or 2), summary and error. summary is the report App::Actions::Check produces as data, with an images array added; each entry is { image, source, status, error }, and status is pulled, present, failed, skipped or deferred. A deferred entry could not be named from the values available before the run, because a job has not produced it yet; it carries expression instead of image, and the job prepares it with its own values before it starts. summary also carries schema_version, which is "preflight_schema_version" in App::Actions::Runner; the schema and the checked-in examples in t/fixtures/ are validated 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. With json => 1 nothing is printed, so the caller's output can be a single document; ready is then a JSON boolean and the summary is the document action-run preflight --json prints, already validated against that schema, so a mismatch dies with an App::Actions::Schema::Invalid exception naming the first key at fault rather than being returned; the command gives that exit 70.

SIDE EFFECTS

Contacts the Docker daemon, starts a probe container and pulls images.

EXAMPLE

my $result = $actions->preflight(json => 1);
print JSON::PP->new->canonical->encode($result->{summary}), "\n";
exit $result->{exit};

API SPECIFICATION

INPUT

The option rules of "new", all optional, plus

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

OUTPUT

{ type => 'hashref' }    # { exit => 0|2, summary => {...}, error => string|undef }

MESSAGES

+--------------------------+-------------------------------+--------------------------------+
| Message                  | Meaning                       | Resolution                     |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to     | An unknown or mistyped option | Use the options of new         |
| App::Actions::preflight  |                               |                                |
| the action-run preflight | The document drifted from its | A bug in this distribution;    |
| --json document does not | schema (an                    | report it                      |
| match ...                | App::Actions::Schema::Invalid)|                                |
+--------------------------+-------------------------------+--------------------------------+

json_schema

Returns the JSON schema of one of the machine-readable documents.

ARGUMENTS

  • $name - check, preflight, cache or logs.

RETURNS

The schema as a hash reference, which is what action-run schema prints. The schemas live in App::Actions::Check, App::Actions::Runner, App::Actions::Cache and App::Actions::Logs, so the contract is available from an installed distribution.

SIDE EFFECTS

None.

EXAMPLE

my $schema = App::Actions->json_schema('check');
print $schema->{title}, "\n";    # action-run check --json

API SPECIFICATION

INPUT

{ name => { type => 'string', optional => 1 } }

OUTPUT

{ type => 'hashref' }

MESSAGES

+--------------------------+-------------------------------+--------------------------------+
| Message                  | Meaning                       | Resolution                     |
+--------------------------+-------------------------------+--------------------------------+
| A document name is       | The name was undef or empty   | Pass check, preflight, cache   |
| required                 |                               | or logs                        |
| Unknown document 'N';    | No such document              | Use one of the names listed    |
| expected check,          |                               |                                |
| preflight, cache or logs |                               |                                |
+--------------------------+-------------------------------+--------------------------------+

list_jobs

Prints one line per expanded job: its id, a tab, and its display name.

ARGUMENTS

Any of the run options of "new". dry_run and quiet are always set, because listing starts nothing and prints only the list.

RETURNS

0.

SIDE EFFECTS

Prints to standard output.

EXAMPLE

$actions->list_jobs(jobs => [ 'test' ]);
# test	test (perl=5.38)
# test	test (perl=5.40)

API SPECIFICATION

INPUT

The option rules of "new", all optional.

OUTPUT

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

MESSAGES

+--------------------------+-------------------------------+--------------------------------+
| Message                  | Meaning                       | Resolution                     |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to     | An unknown or mistyped        | Use the options of new         |
| App::Actions::list_jobs  | override                      |                                |
+--------------------------+-------------------------------+--------------------------------+

TESTING

The suite runs against a fake Docker command, so it needs neither Docker nor a daemon:

prove -l t

One of its tests copies the suite into a temporary directory whose name contains spaces and runs every test file there again, which is how paths with spaces are covered on every platform. The copy is made from MANIFEST, so it holds exactly what the distribution ships and a test that reads a new file finds it there as soon as that file is listed. That doubles the running time; set SPACE_PATH=0 to skip it, or run just that check with prove -l t/space-path.t.

t/locales.t covers the message catalogue: the country-to-language table for GB, US, FR, DE and CN, and the error paths under the en_US.UTF-8, de_DE.UTF-8 and ja_JP.UTF-8 POSIX locales.

The repository ships a pre-commit hook in .githooks/ that runs the schema checks and the live-knob check against the files a commit stages, so a version bump applied to the schema or to the documents but not to both, or a live test's knob added without its documentation or whitelist, is refused where it happens rather than by CI afterwards. Each check starts only when a staged path can affect it. It is off until a clone asks for it:

git config core.hooksPath .githooks

A commit that touches nothing either check reads passes without a check being run at all, and git commit --no-verify skips it for one commit.

The same checks can be run from the command line without attempting a commit:

make check-staged    # against the index
make check-worktree  # against the working tree, unstaged edits included

check-worktree always runs both checks, including files that are not tracked yet, so a new fixture or live test is not skipped. Both targets need the Makefile that perl Makefile.PL writes.

The tests that need a real Docker daemon sit behind one switch, LIVE_TESTING: make test LIVE_TESTING=1 runs them all. t/real-docker.t runs one small workflow end to end with action-run check and the smoke script, t/real-docker-preflight.t the preflight including an image that cannot be pulled, and t/self_test.t the command a user runs - bin/action-run t/lib/self_test.yml . - in a real container rather than the fake one it uses by default. One of them on its own is LIVE_TESTING=1 prove -l t/real-docker.t.

The Live Docker workflow sets the switch and runs t/self_test.t on every push, so the command a user runs is exercised on a daemon in CI rather than only on a machine that has one. The two daemon-specific files stay where a particular daemon is the point: a local LIVE_TESTING=1 run, and the Real Docker on Windows workflow.

The fake Docker executes step scripts with the host's shell, mapping the paths docker exec was given back to the host, so a step's output and the files it writes to GITHUB_ENV and GITHUB_OUTPUT are exercised without a daemon. It runs the command of a docker:// action the same way, so what a one-shot container prints, and the status it exits with, are what the run sees.

The self test runs two workflows the distribution ships: the one above, which proves the command surface, and t/lib/self_test_cli.yml, which runs action-run schema and action-run validate inside a Perl image for every check and preflight document and every cache and logs fixture, including the unready, unreachable, uncounted, empty and prune examples, so the tool is exercised where it is being tested rather than only from the checkout that started it. All four sets are discovered from t/fixtures/check*.json, t/fixtures/preflight*.json, t/fixtures/cache*.json and t/fixtures/logs*.json rather than listed by name, so a new one is validated without editing the workflow. That image gets the tool's dependencies only when it does not have them, so the fake run stays off the network. It is named by digest rather than by the perl:5.38 tag, so the live job pulls the same image every time instead of whatever that tag points at on the day it runs. The test says which half it ran: with LIVE_TESTING=1 it reports that the workflow is in a real container, and without it that the steps ran against the fake and the container path was not exercised.

The live tests read two variables and both carry the LIVE_TESTING prefix, so there is one name to remember: LIVE_TESTING turns them on and LIVE_TESTING_IMAGE names the image they start, alpine:3.20 by default. DOCKER overrides the Docker command. The daemon must run Linux containers. t/live-knobs.t holds the tests, the documentation and the workflow to that prefix, so a rename that misses one of them fails the ordinary suite. The same ground is covered by action-run-smoke, which does not need prove and reports the environment before it runs anything; use it when Docker, not the code, is in doubt. On Windows it is the check to run against Docker Desktop, whose Linux engine action-run-smoke verifies before it starts a container.

The opt-in Real Docker on Windows workflow runs the same test on a Windows host. Dispatch it on a self-hosted Windows runner with Docker Desktop, or set the docker_host input to a Linux daemon. GitHub-hosted Windows runners only run Windows containers, so the job stops there with an explanation.

LIMITATIONS

Only Linux runners can be emulated. Windows and macOS jobs are skipped. Local composite and JavaScript actions are run; remote actions and local docker actions cannot be run. Checkout, setup-perl, cache, artifact, and Codecov actions are treated as no-ops. Use strict_actions to turn unknown actions into failures instead of skips. JavaScript actions need node in the job image. Job outputs, needs.*.outputs, and timeout-minutes are not implemented.

Images are pulled concurrently only where fork() is reliable; on Windows they are pulled one at a time, because Perl's emulation of fork() shares the parent's handles with the child.

Design weaknesses that remain:

  • App::Actions::Runner plans the run and hands the work to five parts (images, containers, steps, parallel jobs and the report), each in its own module under App/Actions/Runner/. The parts are built from copies of the runner's options rather than given the runner, so they can be tested alone, but a change to an option after construction is not seen by them, and the planner is still the largest of them.

  • Errors raised through the catalogue are App::Actions::Error objects carrying the message key as a stable code, and bin/action-run asks for the code rather than matching the text. The errors of App::Actions::Check, App::Actions::Docker, App::Actions::Expression, App::Actions::Process, App::Actions::Cache and App::Actions::Logs are still strings.

  • The option rules are one table, the runner's (see "option_rules" in App::Actions::Runner), but the options are still checked twice: here, where the caller passes them, and in the runner, which is a public class and so checks its own arguments. The cost is a second pass over a dozen keys per call.

  • Unknown options are now an error. A caller that passed an option this module never forwarded (and which therefore had no effect) will now be told so.

  • The Check, Docker, Expression, Process and command-line messages have not been moved into the catalogue yet; see "LIMITATIONS" in App::Actions::I18N.

COMMON PITFALLS

  • Docker must be running, and able to see your project. Run action-run check first if a run fails before any job starts.

  • Only Linux jobs run. Jobs with runs-on: macos-* or windows-* are skipped, not failed.

  • Remote actions are not downloaded. actions/checkout, setup-perl and a few others are treated as no-ops (your project is already mounted). Other remote actions are skipped with a warning; set strict_actions to make them fail instead.

  • Secrets must be passed in. Nothing is read from GitHub. Pass secrets => { NAME => value } (or --secret on the command line). Missing secrets are empty, as on GitHub for a fork.

  • Images are kept. The images a run downloads stay, to make the next run faster. Set keep_images => 0, or use action-run cache --prune, to remove them.

  • run returns a status; it does not die for a failed job. Check the number: 0, 1 or 2.

  • Unknown options are errors. A misspelt option name (job for jobs) stops with Invalid arguments to App::Actions::new.

  • Run as yourself or root. With user set to another user, the job's files (secrets included) must be readable by every user of the computer, and a warning says so.

  • Only run workflows you trust. A workflow can mount any folder of your computer into its containers, as it can on GitHub's own runners.

  • Parallel jobs write to log files, not the terminal. With parallel above 1, read the output with action-run logs.

ENCODING

Workflow and action files are read as UTF-8. Their names, commands and values may contain any Unicode text, including non-ASCII characters and emoji. File and folder names are used as bytes, as your system stores them. Commands, environment variables and secrets are passed to Docker as bytes (UTF-8 for text from the workflow). What the containers print is passed through to your terminal unchanged. Messages from this module are in English unless ACTION_RUN_LANG chooses French (fr), German (de) or Chinese (zh); those are character strings, so the terminal should use UTF-8.

SEE ALSO

AUTHOR

Nigel Horne, <njh at 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

┌─ New ─────────────────────────────────────────────
│ ΞFileSystem
│ workflowFile? : optional PATH
│ projectDir?   : optional PATH
│ options?      : OPTION ⇸ VALUE
│ actions!      : Actions
├───────────────────────────────────────────────────
│ dom options? ⊆ RUNNER_OPTIONS
│ workflowFile? = ⊥ ⇒ raises WorkflowFileRequired
│ abs(workflowFile?) ¬in; regularFiles ⇒ raises WorkflowFileMissing
│ projectDir? = ⊥ ∧ abs(workflowFile?) ¬in; (PATH ⁀ "/.github/workflows/" ⁀ NAME)
│     ⇒ raises ProjectDirRequired
│ let root == (projectDir? ≠ ⊥ ⇒ abs(projectDir?))
│             ∧ (projectDir? = ⊥ ⇒ repository(abs(workflowFile?)))
│ root ¬in; directories ⇒ raises ProjectDirMissing
│ actions!.workflow = Workflow.Load(abs(workflowFile?))
│ actions!.project_dir = root
│ actions!.options = options?
└───────────────────────────────────────────────────

workflow, workflow_file, project_dir

┌─ Accessor ────────────────────────────────────────
│ ΞActions
│ field? : {workflow, workflow_file, project_dir}
│ value! : VALUE
├───────────────────────────────────────────────────
│ value! = θActions.field?
└───────────────────────────────────────────────────

run

┌─ Run ─────────────────────────────────────────────
│ ΞActions
│ overrides? : OPTION ⇸ VALUE
│ exit!      : {0, 1, 2}
├───────────────────────────────────────────────────
│ dom overrides? ⊆ RUNNER_OPTIONS
│ exit! = Runner.Run(options ⊕ overrides?)
└───────────────────────────────────────────────────

preflight

┌─ Preflight ───────────────────────────────────────
│ ΞActions
│ overrides? : OPTION ⇸ VALUE
│ json?      : optional BOOLEAN
│ result!    : Result
├───────────────────────────────────────────────────
│ dom overrides? ⊆ RUNNER_OPTIONS
│ result! = Runner.Preflight(options ⊕ overrides?, json?)
│ result!.exit ∈ {0, 2}
│ json? ⇒ valid(result!.summary, PreflightSchema)
└───────────────────────────────────────────────────

json_schema

DOCUMENT ::= check | preflight | cache | logs

┌─ JsonSchema ──────────────────────────────────────
│ name?   : optional STRING
│ schema! : KEY ⇸ VALUE
├───────────────────────────────────────────────────
│ name? ∈ {⊥, ⟨⟩} ⇒ raises DocumentRequired
│ name? ¬in; DOCUMENT ⇒ raises DocumentUnknown(name?)
│ name? ∈ DOCUMENT ⇒ schema! = owner(name?).json_schema
└───────────────────────────────────────────────────

list_jobs

┌─ ListJobs ────────────────────────────────────────
│ ΞActions
│ overrides? : OPTION ⇸ VALUE
│ exit!      : {0}
├───────────────────────────────────────────────────
│ output' = output ⁀ ⟨ j : selected(jobs ⊕ overrides?) • j.base_id ⁀ TAB ⁀ j.display_name ⟩
│ exit! = 0
└───────────────────────────────────────────────────

STATE DIAGRAM

An App::Actions object only holds options; each call to "run", "preflight" or "list_jobs" builds a new App::Actions::Runner and goes through these states (the full detail of each is in App::Actions::Runner and its parts):

 new(%options)
      |   options checked; workflow loaded (errors are thrown here)
      v
+-------------+
|   READY     |<------------------------------------------------------+
+-------------+                                                       |
   |       |  list_jobs: print "id<TAB>display name" per job; return 0 |
   |       +-------------------------------------------------------->-+
   |
   | run(%overrides) / preflight(%overrides)
   v
+----------------+  daemon unreachable, not Linux containers,
| CHECK DOCKER   |  project not mountable -> print report; status 2
| (skip_check:   |---------------------------------------------------+
|  skipped)      |                                                   |
+----------------+                                                   |
   |                                                                 |
   v                                                                 |
+----------------+  an image cannot be pulled -> status 2            |
| PREPARE IMAGES |------------------------------------------------->-+
| (pull missing, |                                                   |
|  N at a time)  |  preflight stops here: return { exit, summary }   |
+----------------+------------------------------------------------->-+
   |                                                                 |
   | for each selected job (matrix expanded; non-Linux skipped;      |
   | up to "parallel" at a time)                                     |
   v                                                                 |
+----------------+  network, services, job container                 |
| START JOB      |  (a service that stops -> job fails)              |
+----------------+                                                   |
   |                                                                 |
   v                                                                 |
+----------------+  each step: if: condition -> docker exec;         |
| RUN STEPS      |  GITHUB_ENV / PATH / OUTPUT / STATE honoured;     |
|                |  a failed step fails the job (later steps run     |
|                |  only with if: failure() / always())              |
+----------------+                                                   |
   |                                                                 |
   v                                                                 |
+----------------+  containers and network removed (unless keep);    |
| CLEAN UP JOB   |  images kept unless keep_images is false          |
+----------------+                                                   |
   |                                                                 |
   v                                                                 |
+----------------+  status 0 = every job passed, 1 = a job failed    |
| REPORT         |------------------------------------------------->-+
+----------------+                     returns the status

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.