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;

  • env and with must be mappings;

  • a step must have run or uses.

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 - workflow or action; 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 true and false as 1 and '', which are indistinguishable from the number and the empty string, so "stringify" cannot write them as true and false there. 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 uses that 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.

  • mapping returns 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 true and false are read as 1 and '', so "stringify" writes 1 and the empty string.

  • stringify writes arrays and hashes as JSON, with keys sorted, not as Perl would (ARRAY(0x...)).

  • normalize_step checks the shape only. A misspelt key, or a uses that names no real action, is not reported here.

  • trim never 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.