NAME
App::Actions::Util - helpers shared by the App::Actions document readers
SYNOPSIS
Read a workflow file and check that it is a mapping:
use App::Actions::Util qw(load_yaml_mapping mapping);
my $data = load_yaml_mapping('.github/workflows/ci.yml', 'workflow');
my $env = mapping($data->{env}, ['where.workflow_env']);
Check one step of a job:
use App::Actions::Util qw(normalize_step);
my $step = normalize_step($raw_step, ['where.job', { job => 'test' }]);
print $step->{run} // $step->{uses}, "\n";
Turn a YAML value into the text a workflow would see:
use App::Actions::Util qw(stringify);
stringify(JSON::PP::true); # 'true'
stringify([1, 2]); # '[1,2]'
stringify(undef); # ''
Remove spaces from both ends:
use App::Actions::Util qw(trim);
trim(" hello \n"); # 'hello'
DESCRIPTION
Two modules read YAML files: App::Actions::Workflow (workflow files) and App::Actions::Action (action files). They check the files with the same rules:
the file must exist, be valid YAML, and hold a mapping (key: value pairs) at the top;
envandwithmust be mappings;a step must have
runoruses.
This module holds those rules, so both readers apply them in exactly the same way. It also holds "stringify", which turns any YAML value into the text a workflow sees, and "trim".
These functions are for the distribution's own use. They are public only because more than one module calls them. Their interface may change between releases.
About places. When a check fails, the error must say where the bad value was, for example "env of job 'test'". So the functions that check take a place: an array reference holding a message key and its arguments, such as ['where.job_env', { job => 'test' }]. The words are only made when there is an error, so a check that passes costs nothing. They are made in the user's language. A place may also be a plain string.
FUNCTIONS
load_yaml_mapping
Reads a YAML file that must hold a mapping.
ARGUMENTS
$file- the path of the file.$kind-workfloworaction; chooses the wording of the errors.
RETURNS
A hash reference of the document, with its keys in file order.
SIDE EFFECTS
Reads the file.
EXAMPLE
my $workflow = load_yaml_mapping('.github/workflows/ci.yml', 'workflow');
print join(', ', keys %{ $workflow->{jobs} }), "\n";
API SPECIFICATION
INPUT
{
file => { type => 'string', optional => 1 },
kind => { type => 'string', memberof => [ 'workflow', 'action' ] },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
+--------------------------------+--------------------------------+------------------------------+
| Message (kind = workflow) | Meaning | Resolution |
+--------------------------------+--------------------------------+------------------------------+
| No workflow file given | The path was undef or empty | Pass a path |
| Workflow file 'F' does not | No regular file at the path | Check the path |
| exist | | |
| Unable to parse workflow 'F': | The YAML is malformed | Fix the YAML at the position |
| E | | E names |
| Workflow 'F' is empty | The file has no document | Add the workflow |
| Workflow 'F' must contain a | The document is a list or a | Make the top level a mapping |
| YAML mapping | scalar | |
+--------------------------------+--------------------------------+------------------------------+
The action kind reports the same conditions as No action metadata file given, Action metadata file 'F' does not exist, Unable to parse action metadata 'F': E, Action metadata 'F' is empty and Action metadata 'F' must contain a YAML mapping.
PSEUDOCODE
refuse an unknown kind
refuse a missing path, then a path that is not a file
parse the file, keeping key order, and refuse a parse error
refuse an empty document, then one that is not a mapping
return the mapping
mapping
Returns a shallow copy of a value that must be a mapping, or an empty hash when it is absent.
ARGUMENTS
$value- the value from the document, possibly undef.$place-[ $key, \%args ], the catalogue key that names where the value came from.
RETURNS
A new hash reference.
SIDE EFFECTS
None.
EXAMPLE
my $env = mapping($job->{env}, ['where.job_env', { job => 'test' }]);
$env->{EXTRA} = 1; # $job->{env} is unchanged
API SPECIFICATION
INPUT
{
value => { type => 'hashref', optional => 1 },
place => { type => 'arrayref', min => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| PLACE must be a mapping | The value is a list or scalar | Indent it as KEY: VALUE pairs |
+--------------------------+-------------------------------+--------------------------------+
normalize_step
Checks one step of a job or a composite action and returns a copy whose env and with are always hashes.
ARGUMENTS
$step- the step as parsed.$place-[ $key, \%args ]naming the job or file the step is in, such as['where.job', { job => 'test' }].
RETURNS
A new hash reference.
SIDE EFFECTS
None.
EXAMPLE
my $step = normalize_step({ run => 'make test' }, ['where.job', { job => 'build' }]);
print scalar keys %{ $step->{env} }, "\n"; # 0
API SPECIFICATION
INPUT
{
step => { type => 'hashref' },
place => { type => 'arrayref', min => 1 },
}
OUTPUT
{ type => 'hashref' }
MESSAGES
+--------------------------------+--------------------------------+------------------------------+
| Message | Meaning | Resolution |
+--------------------------------+--------------------------------+------------------------------+
| A step in PLACE is not a | The step is a scalar or a list | Start the step with "- run:" |
| mapping | | or "- uses:" |
| env/with of a step in PLACE | env or with is not a mapping | Indent it as KEY: VALUE |
| must be a mapping | | |
| Step field 'F' in PLACE must | run, uses, shell, name, id, if | Quote the value or fix the |
| be a string | or working-directory is nested | indentation |
| A step in PLACE has neither | The step does nothing | Add run or uses |
| 'run' nor 'uses' | | |
+--------------------------------+--------------------------------+------------------------------+
PSEUDOCODE
refuse a step that is not a mapping
copy it, replacing env and with by checked copies
refuse any string field that holds a reference
refuse a step with neither run nor uses
return the copy
stringify
Converts a YAML or JSON value to the string a workflow sees for it.
ARGUMENTS
$value- any value.
RETURNS
'' for undef; 'true' or 'false' for a boolean (a JSON::PP boolean, or on Perl 5.36 and later a core boolean, which is what YAML::PP produces for true and false); the value itself for any other scalar; and canonical JSON for a reference.
SIDE EFFECTS
None.
EXAMPLE
stringify(undef); # ''
stringify(1 == 0); # 'false' on Perl 5.36+, '' before
stringify(JSON::PP::true); # 'true'
stringify([ 1, 'a' ]); # '[1,"a"]'
stringify({ b => 1, a => 2 }); # '{"a":2,"b":1}'
API SPECIFICATION
INPUT
{ value => { type => 'string|arrayref|hashref|object', optional => 1 } }
OUTPUT
{ type => 'string' }
MESSAGES
None.
trim
Removes leading and trailing white space.
ARGUMENTS
$text- a string, possibly undef.
RETURNS
The trimmed string; '' for undef.
SIDE EFFECTS
None; the argument is not modified.
EXAMPLE
trim(" error at line 3\n"); # 'error at line 3'
API SPECIFICATION
INPUT
{ text => { type => 'string', optional => 1 } }
OUTPUT
{ type => 'string' }
MESSAGES
None.
LIMITATIONS
On Perl older than 5.36, YAML::PP loads
trueandfalseas1and'', which are indistinguishable from the number and the empty string, so "stringify" cannot write them astrueandfalsethere. That is the reason the distribution recommends 5.36 for workflows that use boolean matrix values."normalize_step" checks shape, not meaning: an unknown key, or a
usesthat names no real action, is reported later by the run."load_yaml_mapping" reads the whole file into memory; workflow and action files are small, and streaming would buy nothing.
COMMON PITFALLS
A place is not a sentence. Pass
['where.job', { job => 'test' }], not"in job test". A plain string works, but it is not translated.mappingreturns a shallow copy. Changing a value inside it that is itself a reference changes the original too.Booleans need Perl 5.36 or later. On older Perls, YAML
trueandfalseare read as1and'', so "stringify" writes1and the empty string.stringifywrites arrays and hashes as JSON, with keys sorted, not as Perl would (ARRAY(0x...)).normalize_stepchecks the shape only. A misspelt key, or ausesthat names no real action, is not reported here.trimnever changes its argument. Use the return value:$text = trim($text).
ENCODING
"load_yaml_mapping" reads the file as UTF-8, so every string in the returned data is a Perl character string. Any Unicode text is allowed in values, including non-ASCII characters and emoji. Keys are usually ASCII, but need not be. "stringify" and "trim" work on characters. "trim" always removes ASCII white space; whether it also removes a non-ASCII space (such as a no-break space) depends on how Perl stores the string, so do not rely on it. A file in another encoding (such as Latin-1) is likely to fail to parse.
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.
load_yaml_mapping
KIND ::= workflow | action
┌─ LoadYamlMapping ─────────────────────────────────
│ ΞFileSystem
│ file? : optional PATH
│ kind? : KIND
│ doc! : KEY ⇸ VALUE
├───────────────────────────────────────────────────
│ file? = ⊥ ∨ file? = ⟨⟩ ⇒ raises FileRequired(kind?)
│ file? ¬in; regularFiles ⇒ raises FileMissing(kind?, file?)
│ ¬ parses(contents(file?)) ⇒ raises ParseFailed(kind?, file?)
│ parse(contents(file?)) = ⊥ ⇒ raises Empty(kind?, file?)
│ parse(contents(file?)) ¬in; MAPPING ⇒ raises NotMapping(kind?, file?)
│ otherwise doc! = parse(contents(file?))
└───────────────────────────────────────────────────
mapping
┌─ Mapping ─────────────────────────────────────────
│ value? : optional VALUE
│ place? : KEY × (NAME ⇸ VALUE)
│ copy! : KEY ⇸ VALUE
├───────────────────────────────────────────────────
│ value? = ⊥ ⇒ copy! = ∅
│ value? ∈ MAPPING ⇒ copy! = value? ∧ copy! is a new object
│ value? ¬in; MAPPING ∪ {⊥} ⇒ raises MappingInvalid(place?)
└───────────────────────────────────────────────────
normalize_step
STRINGFIELD == {run, uses, shell, working-directory, name, id, if}
┌─ NormalizeStep ───────────────────────────────────
│ step? : VALUE
│ place? : KEY × (NAME ⇸ VALUE)
│ step! : KEY ⇸ VALUE
├───────────────────────────────────────────────────
│ step? ¬in; MAPPING ⇒ raises StepNotMapping(place?)
│ ∃ f : STRINGFIELD | f ∈ dom step? ∧ step?(f) ≠ ⊥ • step?(f) ∈ REF
│ ⇒ raises FieldNotString(f, place?)
│ run ¬in; dom step? ∧ uses ¬in; dom step? ⇒ raises NoCommand(place?)
│ otherwise step! = step? ⊕ {env ↦ Mapping(step?.env), with ↦ Mapping(step?.with)}
└───────────────────────────────────────────────────
stringify
┌─ Stringify ───────────────────────────────────────
│ value? : optional VALUE
│ text! : seq CHAR
├───────────────────────────────────────────────────
│ value? = ⊥ ⇒ text! = ⟨⟩
│ value? ∈ BOOLEAN ⇒ (value? ⇒ text! = "true") ∧ (¬ value? ⇒ text! = "false")
│ value? ∈ SCALAR \ BOOLEAN ⇒ text! = value?
│ value? ∈ REF \ BOOLEAN ⇒ text! = canonicalJSON(value?)
└───────────────────────────────────────────────────
trim
┌─ Trim ────────────────────────────────────────────
│ text? : optional seq CHAR
│ text! : seq CHAR
├───────────────────────────────────────────────────
│ text? = ⊥ ⇒ text! = ⟨⟩
│ text? ≠ ⊥ ⇒ text! is the longest infix of text? that neither
│ begins nor ends with white space
└───────────────────────────────────────────────────
STATE DIAGRAM
The functions keep no state. This is the path a document takes through them:
load_yaml_mapping($file, $kind)
|
v
+---------------------+ no name -> KIND.file_required
| FILE CHECKS | not a file -> KIND.file_missing
+---------------------+
|
v
+---------------------+ not valid YAML -> KIND.parse_failed
| PARSE (UTF-8, keys | empty document -> KIND.empty
| kept in order) | not a mapping -> KIND.not_mapping
+---------------------+
|
v
+---------------------+ mapping($value, $place) for env, with, ...:
| MAPPING (top level) | undef -> {}; a mapping -> a copy;
+---------------------+ anything else -> mapping.invalid at $place
|
| for each step of each job
v
+---------------------+ not a mapping -> step.not_mapping
| normalize_step | env/with not mappings -> mapping.invalid
+---------------------+ run/uses/... not strings -> step.field_not_string
| neither run nor uses -> step.no_command
v
+---------------------+
| CHECKED STEP (copy) | values later shown with stringify / trim
+---------------------+
Every arrow to the right throws an App::Actions::Error with that code;
its message names the place, in the user's language.
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.