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; itsenvis 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=VALUEtext."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.