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.
default_logger
my $logger = default_logger();
$logger->warn('the image cache could not be written');
Returns the Log::Abstraction logger a run uses when it is given none. It prints each message on its own line on standard error, exactly as written: no time, no level and no file name are added, and a message of several lines is printed as it is. It passes on messages of every level, because the callers already decide which messages to send (for example, nothing but errors when the run is quiet). It keeps no copy of the messages, so a long run does not use more and more memory.
Standard error is looked up each time a message is printed, so a forked job whose standard error goes to its log file writes its messages there.
EXAMPLE
my $runner = App::Actions::Runner->new(%options, logger => default_logger());
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'object', isa => 'Log::Abstraction' }
MESSAGES
None.
null_logger
my $logger = null_logger();
Returns a Log::Abstraction logger that throws every message away. This is what logger => 'NULL' (Object::Configure's word for "no logging") becomes, so the code that logs never has to ask whether there is a logger.
EXAMPLE
my $actions = App::Actions->new(workflow_file => $file, logger => 'NULL');
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'object', isa => 'Log::Abstraction' }
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.*, in any order: when B reads env.A, A is worked out first. Each value's own text is interpolated exactly once, so ${{ }} text that comes from somewhere else, such as a commit message, is never evaluated. Variables that refer to each other are an error.
ARGUMENTS
$expression- an App::Actions::Expression.\@layers- hash references; anything else is skipped.$context- the expression context. The expressions see a copy whoseenvis the variables being resolved; the caller's hash is not changed.
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 |
+-----------------------------+------------------------------+-----------------------------+
| Environment variable 'X' | X refers to itself, or to a | Break the loop: give one of |
| refers to itself, directly | variable that refers back | the variables a value that |
| or through other variables | to it | does not use the others |
+-----------------------------+------------------------------+-----------------------------+
PSEUDOCODE
merge the layers into one hash of strings, later layers winning
for each name, in sorted order:
resolve(name)
return the resolved values
resolve(name):
if name is resolved, return its value
if name is being resolved, fail: a cycle
interpolate its text once, with env reading through resolve
remember and return the result
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.
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=VALUEtext."redact" replaces whole values only. A secret that appears split or encoded is not hidden.
"resolve_env" refuses a cycle.
A: ${{ env.B }}withB: ${{ env.A }}, or a variable that uses itself, is the errorrunner.env_cycle, not a partly filled-in value. To add to a variable from an outer layer, give the new value another name."default_logger" does not filter by level. It prints every message it is given. To keep only some levels, or to write them to a file, give the run a Log::Abstraction logger of your own (see "new" in App::Actions).
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?))
└───────────────────────────────────────────────────
default_logger
┌─ DefaultLogger ───────────────────────────────────
│ logger! : LogAbstraction
├───────────────────────────────────────────────────
│ ∀ level : LEVEL; m : seq CHAR •
│ logger!.log(level, m) ⇒ stderr' = stderr ⁀ m ⁀ ⟨NEWLINE⟩
│ logger!.history = ⟨⟩
└───────────────────────────────────────────────────
null_logger
┌─ NullLogger ──────────────────────────────────────
│ logger! : LogAbstraction
├───────────────────────────────────────────────────
│ ∀ level : LEVEL; m : seq CHAR • logger!.log(level, m) ⇒ stderr' = stderr
└───────────────────────────────────────────────────
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?
│ let refs == {a, b : dom merged | interpolating merged(a) reads env.b}
│ (∃ n : dom merged • (n, n) ∈ refs⁺) ⇒ raise runner.env_cycle
│ otherwise env! = μ e • {n : dom merged •
│ n ↦ interpolate(merged(n), context? ⊕ {env ↦ e})}
│ (each merged(n) is interpolated once; a result is never re-read)
└───────────────────────────────────────────────────
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.