NAME

App::Actions::Runner::Support - helpers shared by the parts of the runner

SYNOPSIS

use App::Actions::Runner::Support qw(resolve_env status_flags trim_error);

my $env = resolve_env($expression, [ $workflow_env, $job_env ], $context);
my $status = status_flags($failed);

Write a file the container will read, safely:

use App::Actions::Runner::Support qw(write_container_file);

write_container_file("$temp/scripts/step-1.sh", "echo hello\n", 'the step script');

Find a local action, refusing one that leads out of the project:

use App::Actions::Runner::Support qw(local_action_dir);

my $found = local_action_dir($project_dir, './.github/actions/setup');
print "$found->{relative} is in $found->{dir}\n";

Pass a job's variables to Docker without showing their values to other users:

use App::Actions::Runner::Support qw(env_args);

$docker->container_start(@{ env_args({ TOKEN => $token }) }, 'alpine', 'sleep', '60');

DESCRIPTION

In short: small helper functions that several parts of the runner need. You rarely use them on your own.

The runner is split into a planner (App::Actions::Runner), an image preparer (App::Actions::Runner::Images), a container manager (App::Actions::Runner::Containers), a step executor (App::Actions::Runner::Steps), a parallel scheduler (App::Actions::Runner::Parallel) and a reporter (App::Actions::Runner::Report). The functions here are the ones more than one of them needs. None keeps state.

FUNCTIONS

All are exported on request.

status_flags

my $status = status_flags($failed);

Returns the values of the status functions for a job or a step that has, or has not, failed so far: { success => 1, failure => 0, cancelled => 0 } or the reverse. A run is never cancelled.

EXAMPLE

$expression->condition('failure()', $context, status_flags(1));    # true

API SPECIFICATION

INPUT

{ failed => { type => 'boolean' } }

OUTPUT

{ type => 'hashref' }

MESSAGES

None.

chomp_error

my $text = chomp_error($@);

Returns an exception as a string without its trailing white space. The location, if any, is kept: this is for messages that have always shown it.

EXAMPLE

print STDERR 'action-run: ', chomp_error($@), "\n";

API SPECIFICATION

INPUT

{ error => { type => 'any', optional => 1 } }

OUTPUT

{ type => 'string' }

MESSAGES

None.

trim_error

my $reason = trim_error($@);

Returns an exception as a sentence: its message without the source location (see "describe" in App::Actions::Error), or undef when there was no error. Everything that ends up in a summary or a report goes through here, because a location is useful in a stack trace and noise in a report someone reads.

EXAMPLE

my $ok = eval { $docker->pull($image); 1 };
$record->{error} = trim_error($@) unless $ok;

API SPECIFICATION

INPUT

{ error => { type => 'any', optional => 1 } }

OUTPUT

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

MESSAGES

None.

resolve_env

my $env = resolve_env($expression, \@layers, $context);

Merges layers of environment variables, later layers winning, and resolves the ${{ }} expressions in their values. A value may refer to another variable through env.*, so the values are resolved again until nothing changes, at most ten times.

ARGUMENTS

  • $expression - an App::Actions::Expression.

  • \@layers - hash references; anything else is skipped.

  • $context - the expression context; its env is replaced by the variables being resolved.

RETURNS

A hash reference of name to string.

SIDE EFFECTS

None.

EXAMPLE

my $env = resolve_env($expression, [ { A => 'x' }, { B => '${{ env.A }}y' } ], {});
# { A => 'x', B => 'xy' }

API SPECIFICATION

INPUT

{
	expression => { type => 'object', can => 'interpolate' },
	layers     => { type => 'arrayref' },
	context    => { type => 'hashref' },
}

OUTPUT

{ type => 'hashref' }

MESSAGES

+-----------------------------+------------------------------+-----------------------------+
| Message                     | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| Unable to evaluate          | A value holds an expression  | Fix the expression in the   |
| environment variable 'X': E | that does not parse or run   | workflow's env              |
+-----------------------------+------------------------------+-----------------------------+

PSEUDOCODE

merge the layers into one hash of strings, later layers winning
repeat up to ten times:
	interpolate every value with the hash itself as env
	stop when no value changed
return the hash

boolean_value

my $flag = boolean_value($expression, $value, $context, $status);

Reads a workflow boolean such as continue-on-error: a YAML boolean, one of the words true, yes, 1, false, no, 0, or an expression whose result is truthy.

EXAMPLE

boolean_value($expression, 'yes', {}, status_flags(0));                     # 1
boolean_value($expression, '${{ matrix.experimental }}', $context, $status); # as the matrix says

API SPECIFICATION

INPUT

{
	expression => { type => 'object', can => 'condition' },
	value      => { type => 'any', optional => 1 },
	context    => { type => 'hashref' },
	status     => { type => 'hashref', optional => 1 },
}

OUTPUT

{ type => 'boolean' }

MESSAGES

Whatever the expression's evaluation croaks with.

has_status_function

Says whether a condition calls success(), failure(), always() or cancelled(). One that does not is given an implicit success().

EXAMPLE

has_status_function('always() && matrix.os == "linux"');    # 1

API SPECIFICATION

INPUT

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

OUTPUT

{ type => 'boolean' }

MESSAGES

None.

write_container_file

write_container_file($path, $contents, $description);

Writes a file that a container will read, in binary mode. Scripts must keep LF line endings: on Windows a text-mode handle would write CRLF, and bash rejects a script whose lines end in \r.

ARGUMENTS

  • $path - where to write.

  • $contents - the bytes.

  • $description - what the file is, for the error message (for example "the sudo shim").

RETURNS

Nothing useful.

SIDE EFFECTS

Creates or replaces the file.

EXAMPLE

write_container_file("$temp/scripts/step-1.sh", "echo hi\n", 'the step script');

API SPECIFICATION

INPUT

{
	path        => { type => 'string', min => 1 },
	contents    => { type => 'string' },
	description => { type => 'string' },
}

OUTPUT

{ type => 'undef' }

MESSAGES

+-----------------------------+------------------------------+-----------------------------+
| Message                     | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| Unable to write WHAT: ERROR | The file could not be opened | Check the temporary         |
|                             | or written                   | directory's space and mode  |
+-----------------------------+------------------------------+-----------------------------+

local_action_path

my $relative = local_action_path('./.github/actions/test');

Resolves a ./path/to/action reference to a path relative to the project. Both the run and the preflight resolve references this way, so an action is read from one place whichever finds it first.

EXAMPLE

local_action_path('./.github/actions/build/');    # .github/actions/build

API SPECIFICATION

INPUT

{ uses => { type => 'string' } }

OUTPUT

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

MESSAGES

+-----------------------------+------------------------------+-----------------------------+
| Message                     | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| Local action 'X' does not   | The reference is "./" alone  | Name the action's directory |
| name a directory inside the |                              |                             |
| project                     |                              |                             |
| Local action 'X' must not   | The path contains ".."       | Keep the action inside the  |
| escape the project directory|                              | repository                  |
+-----------------------------+------------------------------+-----------------------------+

local_action_dir

my $location = local_action_dir($project_dir, './.github/actions/test');
print "$location->{dir}\n";

Resolves a ./path/to/action reference, as "local_action_path" does, to the directory on the host, and checks that the directory really is inside the project. The textual check refuses ..; this one also refuses a symbolic link in the repository that leads out of it, which would otherwise have the host read an action.yml from anywhere the link pointed. A directory that does not exist is returned as it would be, for the caller to report.

EXAMPLE

my $location = local_action_dir('/src/app', './.github/actions/build/');
# { relative => '.github/actions/build', dir => '/src/app/.github/actions/build' }

API SPECIFICATION

INPUT

{
	project_dir => { type => 'string', min => 1 },
	uses        => { type => 'string' },
}

OUTPUT

{
	type => 'hashref',
	schema => {
		relative => { type => 'string', min => 1 },
		dir      => { type => 'string', min => 1 },
	},
}

MESSAGES

+-----------------------------+------------------------------+-----------------------------+
| Message                     | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| Local action 'X' must not   | The path contains "..", or   | Keep the action inside the  |
| escape the project directory| a link in it leads outside   | repository, as a directory  |
+-----------------------------+------------------------------+-----------------------------+

And the messages of "local_action_path".

action_inputs

my $inputs = action_inputs($expression, $action, $step, $uses, $context, $status);

Resolves a local action's inputs: the defaults its metadata declares, replaced by the calling step's with. An input the action requires and neither supplies is an error.

EXAMPLE

my $inputs = action_inputs($expression, $action, { with => { name => 'x' } },
	'./.github/actions/greet', $context, status_flags(0));

API SPECIFICATION

INPUT

{
	expression => { type => 'object', can => 'interpolate' },
	action     => { type => 'object', isa => 'App::Actions::Action' },
	step       => { type => 'hashref' },
	uses       => { type => 'string' },
	context    => { type => 'hashref' },
	status     => { type => 'hashref', optional => 1 },
}

OUTPUT

{ type => 'hashref' }

MESSAGES

+-----------------------------+------------------------------+-----------------------------+
| Message                     | Meaning                      | Resolution                  |
+-----------------------------+------------------------------+-----------------------------+
| Action 'X' requires the     | A required input has no      | Pass it with "with:"        |
| input 'Y'                   | default and was not given    |                             |
+-----------------------------+------------------------------+-----------------------------+

PSEUDOCODE

start with every declared input's default, interpolated, or ''
overlay every value of the step's with, interpolated
croak naming the first required input (by name) that is still empty
return the inputs

redact

my $safe = redact($docker, $text);

Masks the secrets the Docker object knows about, when it can; a caller-supplied Docker object need not offer redaction.

EXAMPLE

print redact($docker, "token is $token\n");    # token is ***

API SPECIFICATION

INPUT

{
	docker => { type => 'object' },
	text   => { type => 'string', optional => 1 },
}

OUTPUT

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

MESSAGES

None.

env_args

push @args, @{ env_args(\%env) };

Turns an environment into docker arguments: -e and an App::Actions::Docker::Variable for each variable, sorted by name so the command line is the same on every run. Each variable reads as NAME=VALUE; App::Actions::Docker passes its value to the client through the environment, so it never appears among the client's arguments, which every user of the host can read.

EXAMPLE

my $args = env_args({ B => 2, A => 1 });
print join(' ', @{$args}), "\n";    # -e A=1 -e B=2

API SPECIFICATION

INPUT

{ env => { type => 'hashref' } }

OUTPUT

{ type => 'arrayref' }

MESSAGES

None.

LIMITATIONS

  • The functions do not validate their arguments: they are called many times per step by the other parts of the runner, which validate what they were given when they are constructed.

  • "resolve_env" gives up after ten passes. A variable that refers to itself, or two that refer to each other, keep whatever the tenth pass made of them rather than being reported.

COMMON PITFALLS

  • Arguments are not checked. These functions trust their callers, for speed. Pass what the documentation says.

  • "write_container_file" replaces the file. It removes the old file and creates a new one with mode 0600; it never writes through a symbolic link. If someone else's file is in the way (in a shared folder), it fails rather than overwrite it.

  • "local_action_dir" refuses links out of the project, with runner.local_action_escapes. A link that stays inside the project is allowed. A path that does not exist is returned as it is, for the caller to report.

  • "env_args" returns objects, not strings. Pass them to an App::Actions::Docker method so that their values are kept off the command line; elsewhere they turn into NAME=VALUE text.

  • "redact" replaces whole values only. A secret that appears split or encoded is not hidden.

  • "resolve_env" stops after ten passes, so a variable that refers to itself keeps a partly filled-in value.

ENCODING

Environment names and values, step text and secrets are Perl character strings and may contain any Unicode text, including non-ASCII characters and emoji. "write_container_file" writes the bytes it is given: encode text (for example as UTF-8) before you pass it. Paths are used as bytes. Action paths (./...) should be ASCII, as on GitHub.

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.

status_flags

┌─ StatusFlags ─────────────────────────────────────
│ failed? : 𝔹
│ s!      : {success, failure, cancelled} → 𝔹
├───────────────────────────────────────────────────
│ s!(success) = ¬ failed? ∧ s!(failure) = failed? ∧ s!(cancelled) = false
└───────────────────────────────────────────────────

chomp_error

┌─ ChompError ──────────────────────────────────────
│ error? : VALUE
│ s!     : seq CHAR
├───────────────────────────────────────────────────
│ s! = rtrim(string(error?))
└───────────────────────────────────────────────────

trim_error

┌─ TrimError ───────────────────────────────────────
│ error? : VALUE
│ s!     : optional seq CHAR
├───────────────────────────────────────────────────
│ error? = ⊥ ∨ string(error?) = ⟨⟩ ⇒ s! = ⊥
│ otherwise s! = Describe(error?)
└───────────────────────────────────────────────────

resolve_env

┌─ ResolveEnv ──────────────────────────────────────
│ layers?  : seq (NAME ⇸ VALUE)
│ context? : CONTEXT
│ env!     : NAME ⇸ seq CHAR
├───────────────────────────────────────────────────
│ let merged == ⊕/ layers?
│ env! = fix(λ e • {n ↦ interpolate(e(n), context? ⊕ {env ↦ e})}, merged, 10)
└───────────────────────────────────────────────────

boolean_value

┌─ BooleanValue ────────────────────────────────────
│ value? : VALUE
│ b!     : 𝔹
├───────────────────────────────────────────────────
│ value? ∈ REF ⇒ b! = truthy(value?)
│ value? = ⊥ ∨ trim(value?) = ⟨⟩ ⇒ b! = false
│ trim(value?) ∈ TRUE_WORD ⇒ b! = true
│ trim(value?) ∈ FALSE_WORD ⇒ b! = false
│ otherwise b! = condition(trim(value?))
└───────────────────────────────────────────────────

has_status_function

┌─ HasStatusFunction ───────────────────────────────
│ condition? : optional seq CHAR
│ b!         : 𝔹
├───────────────────────────────────────────────────
│ b! ⇔ condition? ≠ ⊥ ∧ condition? contains f( for some f ∈ STATUS
└───────────────────────────────────────────────────

write_container_file

┌─ WriteContainerFile ──────────────────────────────
│ ΔFileSystem
│ path?, contents? : seq CHAR
├───────────────────────────────────────────────────
│ files' = files ⊕ {path? ↦ contents?}
└───────────────────────────────────────────────────

local_action_path

┌─ LocalActionPath ─────────────────────────────────
│ uses? : seq CHAR
│ rel!  : seq CHAR
├───────────────────────────────────────────────────
│ rel! = stripSlashes(stripDotSlash(uses?))
│ rel! ≠ ⟨⟩ ∧ ".." ¬in; segments(rel!)
└───────────────────────────────────────────────────

local_action_dir

┌─ LocalActionDir ──────────────────────────────────
│ projectDir?, uses? : seq CHAR
│ loc!               : LOCATION
├───────────────────────────────────────────────────
│ loc!.relative = localActionPath(uses?)
│ loc!.dir = projectDir? ⁀ "/" ⁀ loc!.relative
│ isDirectory(loc!.dir) ⇒ realpath(loc!.dir) ∈ subtree(realpath(projectDir?))
└───────────────────────────────────────────────────

action_inputs

┌─ ActionInputs ────────────────────────────────────
│ declared : NAME ⇸ INPUT
│ with?    : NAME ⇸ VALUE
│ inputs!  : NAME ⇸ seq CHAR
├───────────────────────────────────────────────────
│ inputs! = {n : dom declared • n ↦ interpolate(default(n))} ⊕
│           {n : dom with? • n ↦ interpolate(with?(n))}
│ ∀ n : dom declared | required(n) • inputs!(n) ≠ ⟨⟩
└───────────────────────────────────────────────────

redact

┌─ Redact ──────────────────────────────────────────
│ text? : optional seq CHAR
│ s!    : optional seq CHAR
├───────────────────────────────────────────────────
│ text? = ⊥ ⇒ s! = ⊥
│ docker can redact ⇒ s! = docker.redact(text?)
│ otherwise s! = text?
└───────────────────────────────────────────────────

env_args

┌─ EnvArgs ─────────────────────────────────────────
│ env? : NAME ⇸ seq CHAR
│ a!   : seq seq CHAR
├───────────────────────────────────────────────────
│ a! = ⁀/ ⟨n : sorted(dom env?) • ⟨"-e", n ⁀ "=" ⁀ env?(n)⟩⟩
└───────────────────────────────────────────────────

STATE DIAGRAM

The functions keep no state. The one with a life cycle is "write_container_file", which takes a file in a runner folder through these states:

  write_container_file($path, $contents, $what)
        |
        v
+------------------+  a file this run wrote before, or a
| REMOVE OLD NAME  |  symbolic link: removed (unlink)
+------------------+  (in a shared sticky folder, another user's
        |              file cannot be removed and stays)
        v
+------------------+  O_CREAT | O_EXCL: fails if anything
| CREATE NEW FILE  |  is still at the name; never follows a link
| mode 0600        |----------------------------------------------+
+------------------+                                              |
        |                                                         |
        v                                                         |
+------------------+  print fails (disk full, ...)                |
| WRITE AND CLOSE  |--------------------------------------------->+
+------------------+                                              |
        |                                                         v
        v                                         +---------------------------+
+------------------+                              | throw runner.write_failed |
| WRITTEN          |                              | naming $what and the error|
+------------------+                              +---------------------------+

And local_action_dir($project, $uses):

  "./path" -> relative path -> exists? --no--> returned as it is
                                 | yes
                                 v
                 real path inside the project? --no--> throw
                                 | yes                 runner.local_action_escapes
                                 v
                           { relative, dir }

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.