## 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):

```perl
    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:

```perl
    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:

```perl
    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:

```perl
    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:

```perl
    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](https://metacpan.org/pod/action-run).

How it works, in brief:

- 1. It reads the workflow file ([App::Actions::Workflow](https://metacpan.org/pod/App%3A%3AActions%3A%3AWorkflow)) 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](https://metacpan.org/pod/App%3A%3AActions%3A%3AProcess) 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](https://metacpan.org/pod/action-run)
for the details.

Errors raised by this module, [App::Actions::Workflow](https://metacpan.org/pod/App%3A%3AActions%3A%3AWorkflow) and
[App::Actions::Action](https://metacpan.org/pod/App%3A%3AActions%3A%3AAction) come from the catalogue in [App::Actions::I18N](https://metacpan.org/pod/App%3A%3AActions%3A%3AI18N).
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](https://metacpan.org/pod/App%3A%3AActions%3A%3ACache), 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](https://metacpan.org/pod/App%3A%3AActions%3A%3ADocker) interface, which is how the tests run without one.

#### Returns

An `App::Actions` object.

#### Side Effects

Reads and parses the workflow file.

#### Example

```perl
    # 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

```perl
    {
            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

```perl
    { 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](https://metacpan.org/pod/App%3A%3AActions%3A%3AWorkflow#load) 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

```perl
    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

```perl
    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"](#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

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

#### Api Specification

##### Input

The option rules of ["new"](#new), all optional, without `workflow_file` and
`project_dir`.

##### Output

```perl
    { 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](https://metacpan.org/pod/App%3A%3AActions%3A%3ARunner) 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"](#new), overriding the object's.

#### Returns

A hash of `exit` (0 or 2), `summary` and `error`.  `summary` is the report
[App::Actions::Check](https://metacpan.org/pod/App%3A%3AActions%3A%3ACheck) 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](https://metacpan.org/pod/App%3A%3AActions%3A%3ARunner#preflight_schema_version); 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](https://metacpan.org/pod/App%3A%3AActions%3A%3ASchema%3A%3AInvalid)
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

```perl
    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"](#new), all optional, plus

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

##### Output

```perl
    { 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](https://metacpan.org/pod/App%3A%3AActions%3A%3ACheck), [App::Actions::Runner](https://metacpan.org/pod/App%3A%3AActions%3A%3ARunner),
[App::Actions::Cache](https://metacpan.org/pod/App%3A%3AActions%3A%3ACache) and [App::Actions::Logs](https://metacpan.org/pod/App%3A%3AActions%3A%3ALogs), so the contract is
available from an installed distribution.

#### Side Effects

None.

#### Example

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

#### Api Specification

##### Input

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

##### Output

```perl
    { 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"](#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

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

#### Api Specification

##### Input

The option rules of ["new"](#new), all optional.

##### Output

```perl
    { 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](https://metacpan.org/pod/App%3A%3AActions%3A%3ARunner) 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](https://metacpan.org/pod/App%3A%3AActions%3A%3AError)
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](https://metacpan.org/pod/App%3A%3AActions%3A%3ACheck), [App::Actions::Docker](https://metacpan.org/pod/App%3A%3AActions%3A%3ADocker), [App::Actions::Expression](https://metacpan.org/pod/App%3A%3AActions%3A%3AExpression),
[App::Actions::Process](https://metacpan.org/pod/App%3A%3AActions%3A%3AProcess), [App::Actions::Cache](https://metacpan.org/pod/App%3A%3AActions%3A%3ACache) and [App::Actions::Logs](https://metacpan.org/pod/App%3A%3AActions%3A%3ALogs) are
still strings.
- The option rules are one table, the runner's (see
["option\_rules" in App::Actions::Runner](https://metacpan.org/pod/App%3A%3AActions%3A%3ARunner#option_rules)), 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](https://metacpan.org/pod/App%3A%3AActions%3A%3AI18N#LIMITATIONS).

## 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

- [Configure an Object at Runtime](https://metacpan.org/pod/Object%3A%3AConfigure)
- [Test Dashboard](https://nigelhorne.github.io/App-Actions/coverage/)

## 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?) ∉ regularFiles ⇒ raises WorkflowFileMissing
    │ projectDir? = ⊥ ∧ abs(workflowFile?) ∉ (PATH ⁀ "/.github/workflows/" ⁀ NAME)
    │     ⇒ raises ProjectDirRequired
    │ let root == (projectDir? ≠ ⊥ ⇒ abs(projectDir?))
    │             ∧ (projectDir? = ⊥ ⇒ repository(abs(workflowFile?)))
    │ root ∉ 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? ∉ 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"](#run),
["preflight"](#preflight) or ["list\_jobs"](#list_jobs) builds a new [App::Actions::Runner](https://metacpan.org/pod/App%3A%3AActions%3A%3ARunner) and goes
through these states (the full detail of each is in [App::Actions::Runner](https://metacpan.org/pod/App%3A%3AActions%3A%3ARunner)
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.
