NAME
App::Actions::Workflow - parse and expand a GitHub Actions workflow
SYNOPSIS
List every job a workflow will run, after the matrix is expanded:
use App::Actions::Workflow;
my $workflow = App::Actions::Workflow->load('.github/workflows/perl-ci.yml');
for my $job (@{ $workflow->expand_jobs }) {
print $job->{display_name}, "\n"; # test (perl=5.38), test (perl=5.40), ...
}
Find one job and see which image it would run in:
my $job = $workflow->job_by_id('test');
my ($first) = grep { $_->{base_id} eq 'test' } @{ $workflow->expand_jobs };
my $runs_on = App::Actions::Workflow->runs_on_text($first->{'runs-on'});
my $image = App::Actions::Workflow->container_image($first, $runs_on, 'ubuntu:latest');
Skip jobs that need macOS or Windows:
for my $job (@{ $workflow->expand_jobs }) {
my $runs_on = App::Actions::Workflow->runs_on_text($job->{'runs-on'});
next unless App::Actions::Workflow->is_linux_runner($runs_on);
...
}
Turn a Perl version into an image name:
App::Actions::Workflow->perl_image_for_version('v5.40'); # perl:5.40
DESCRIPTION
A GitHub Actions workflow is a YAML file, usually in .github/workflows/. It lists jobs; each job has steps. A job can have a matrix: a list of values (for example several Perl versions), and GitHub runs one copy of the job for each combination of values.
This module:
reads a workflow file and checks its shape ("load");
expands each job's matrix into concrete jobs, applying
includeandexcludeas GitHub does, and gives each one a display name such astest (perl=5.40)("expand_jobs");decides which Docker image each job runs in: the job's own
container, else a Perl image for a setup-perl step, else an Ubuntu image that matchesruns-on("container_image").
It does not run anything. App::Actions::Runner does that.
Every error is an App::Actions::Error with a stable code (such as workflow.no_jobs) and a message in the language ACTION_RUN_LANG chooses (English by default). The error points at the line of your code that called this module.
METHODS
load
Parses a workflow file.
ARGUMENTS
$file- the path of the workflow.
RETURNS
An App::Actions::Workflow object.
SIDE EFFECTS
Reads the file.
EXAMPLE
my $workflow = App::Actions::Workflow->load('.github/workflows/ci.yml');
printf "%s has %d jobs\n", $workflow->name, scalar @{ $workflow->jobs };
API SPECIFICATION
INPUT
{ file => { type => 'string', min => 1 } }
OUTPUT
{ type => 'object', isa => 'App::Actions::Workflow' }
MESSAGES
+-----------------------------------+------------------------------+-------------------------------+
| Message | Meaning | Resolution |
+-----------------------------------+------------------------------+-------------------------------+
| No workflow file given | The path was undef or empty | Pass a path |
| Workflow file 'F' does not exist | No file at the path | Check the path |
| Unable to parse workflow 'F': E | The YAML is malformed | Fix the YAML |
| Workflow 'F' is empty | No document in the file | Add the workflow |
| Workflow 'F' must contain a YAML | The top level is not a | Make it a mapping |
| mapping | mapping | |
| Workflow 'F' has no 'jobs' | jobs is missing or not a | Add a jobs: mapping |
| mapping | mapping | |
| Job 'J' must be a mapping | A job is a scalar or a list | Indent the job's keys |
| Job 'J' has no 'steps' list | steps is missing or not a | Add a steps: list |
| | list | |
| Job 'J' container must be a | container is a list | Give an image name or a |
| mapping or a string | | mapping with image: |
| Job 'J' services must be a | services is not a mapping | Name each service |
| mapping | | |
| PLACE must be a mapping | env, defaults or strategy is | Indent it as KEY: VALUE |
| | not a mapping | |
| PLACE run must be a mapping | defaults.run is not a | Indent it as KEY: VALUE |
| | mapping | |
+-----------------------------------+------------------------------+-------------------------------+
The step messages of "normalize_step" in App::Actions::Util apply to every step.
PSEUDOCODE
read the file as a YAML mapping
refuse a document whose jobs is not a mapping
for each job, in file order:
normalise it (needs, container, services, env, defaults, strategy, steps)
return the workflow with its name, env, defaults and jobs
file, name, env, defaults, jobs, raw
my $path = $workflow->file;
my $name = $workflow->name; # the name: key, or the file's base name
my $env = $workflow->env; # workflow-level env, a hash reference
my $defs = $workflow->defaults; # workflow-level defaults, a hash reference
my $jobs = $workflow->jobs; # array reference, in file order
my $raw = $workflow->raw; # the parsed document, untouched
Read-only accessors. The references they return are the object's own, so a caller that wants to change one should copy it first.
API SPECIFICATION
INPUT
{}
OUTPUT
file, name: { type => 'string' }
env, defaults, raw: { type => 'hashref' }
jobs: { type => 'arrayref' }
MESSAGES
None.
EXAMPLE
print $_->{id}, "\n" for @{ $workflow->jobs };
job_by_id
Finds a job by its key in the jobs mapping.
ARGUMENTS
$id- the job's key.
RETURNS
The job's hash reference, or undef (also in list context) when there is no such job.
SIDE EFFECTS
None.
EXAMPLE
my $test = $workflow->job_by_id('test') or die "no test job\n";
API SPECIFICATION
INPUT
{ id => { type => 'string' } }
OUTPUT
{ type => 'hashref', optional => 1 }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to ... | The id is missing or a | Pass the job's key |
| | reference | |
+--------------------------+-------------------------------+--------------------------------+
expand_jobs
Expands every job's matrix into concrete job instances.
ARGUMENTS
None.
RETURNS
An array reference with one hash reference per matrix combination, in job order. Each instance is a copy of its job with base_id, matrix, matrix_index, matrix_total, display_name and setup_perl_version added.
SIDE EFFECTS
None.
EXAMPLE
# jobs: { test: { strategy: { matrix: { perl: ['5.38', '5.40'] } }, ... } }
for my $job (@{ $workflow->expand_jobs }) {
print "$job->{display_name}\n"; # test (perl=5.38), test (perl=5.40)
}
API SPECIFICATION
INPUT
{}
OUTPUT
{ type => 'arrayref' }
MESSAGES
+--------------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------------+-------------------------------+--------------------------------+
| strategy.matrix must be a | matrix is a list or a string | Make it a mapping of keys to |
| mapping | | lists |
| Matrix key 'K' must be a list | The key's list is empty | Give it a value, or remove it |
| with at least one value | | |
| Each matrix exclude entry must | An exclude item is not a | Write each as KEY: VALUE |
| be a mapping | mapping | |
| Each matrix include entry must | An include item is not a | Write each as KEY: VALUE |
| be a mapping | mapping | |
+--------------------------------+-------------------------------+--------------------------------+
PSEUDOCODE
for each job:
expand its matrix into combinations
for each combination, at index i of n:
copy the job, adding base_id, matrix, i, n,
the display name and any setup-perl version
return the instances
runs_on_text
Renders a job's runs-on as one string, with matrix values substituted.
ARGUMENTS
$runs_on- theruns-onvalue: a string, a list, or undef.$matrix- optional; the job's matrix values.
RETURNS
The labels joined by spaces, as a string.
SIDE EFFECTS
None.
EXAMPLE
$workflow->runs_on_text('${{ matrix.os }}', { os => 'ubuntu-24.04' }); # ubuntu-24.04
$workflow->runs_on_text([ 'self-hosted', 'linux' ]); # self-hosted linux
API SPECIFICATION
INPUT
{
runs_on => { type => 'string|arrayref', optional => 1 },
matrix => { type => 'hashref', optional => 1 },
}
OUTPUT
{ type => 'string' }
MESSAGES
+--------------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------------+-------------------------------+--------------------------------+
| 'runs-on' must be a string or | runs-on is a mapping (such as | Use a label or a list of |
| a list | a runner group) | labels |
+--------------------------------+-------------------------------+--------------------------------+
is_linux_runner
Says whether a runs-on text names a runner a Linux container can stand in for.
ARGUMENTS
$runs_on- the text from "runs_on_text".
RETURNS
1 when no label names Windows or macOS, otherwise 0.
SIDE EFFECTS
None.
EXAMPLE
$workflow->is_linux_runner('ubuntu-latest'); # 1
$workflow->is_linux_runner('self-hosted linux'); # 1
$workflow->is_linux_runner('windows-2022'); # 0
API SPECIFICATION
INPUT
{ runs_on => { type => 'string', optional => 1 } }
OUTPUT
{ type => 'integer', memberof => [ 0, 1 ] }
MESSAGES
None.
default_image_for_runner
Chooses the image a job with no container runs in, from its runs-on.
ARGUMENTS
$runs_on- the text from "runs_on_text".$fallback- the image to use when the runner names no Ubuntu.
RETURNS
ubuntu:VERSION for ubuntu-VERSION, ubuntu:latest for ubuntu-latest, otherwise $fallback.
SIDE EFFECTS
None.
EXAMPLE
$workflow->default_image_for_runner('ubuntu-22.04', 'perl:5.40'); # ubuntu:22.04
$workflow->default_image_for_runner('self-hosted', 'perl:5.40'); # perl:5.40
API SPECIFICATION
INPUT
{
runs_on => { type => 'string', optional => 1 },
fallback => { type => 'string', optional => 1 },
}
OUTPUT
{ type => 'string', optional => 1 }
MESSAGES
None.
perl_image_for_version
Turns a setup-perl version into an image name.
ARGUMENTS
$version- the Perl version, such as5.38orv5.40; undef, empty,devandlatestall mean the newest.$template- optional; the image name with%swhere the version goes, or a repository name to which:VERSIONis appended. Defaults toperl:%s.
RETURNS
The image name.
SIDE EFFECTS
None.
EXAMPLE
$workflow->perl_image_for_version('5.38'); # perl:5.38
$workflow->perl_image_for_version('v5.40', 'ghcr.io/me/perl:%s'); # ghcr.io/me/perl:5.40
$workflow->perl_image_for_version('dev', 'perl'); # perl:latest
API SPECIFICATION
INPUT
{
version => { type => 'string', optional => 1 },
template => { type => 'string', optional => 1 },
}
OUTPUT
{ type => 'string', min => 1 }
MESSAGES
None.
container_image
Chooses the Docker image for a job.
ARGUMENTS
$job- an instance from "expand_jobs".$runs_on- the text from "runs_on_text".$fallback- the image for a runner that names no Ubuntu.$perl_template- optional; see "perl_image_for_version".
RETURNS
container.image with its matrix values resolved, if the job has one; else the image for a setup-perl step's version; else "default_image_for_runner". A github.* or env.* reference in container.image is left as written, for a caller that knows those values.
SIDE EFFECTS
None.
EXAMPLE
my $image = $workflow->container_image($job, 'ubuntu-latest', 'perl:latest');
API SPECIFICATION
INPUT
{
job => { type => 'hashref' },
runs_on => { type => 'string', optional => 1 },
fallback => { type => 'string', optional => 1 },
perl_template => { type => 'string', optional => 1 },
}
OUTPUT
{ type => 'string', optional => 1 }
MESSAGES
+--------------------------+-------------------------------+--------------------------------+
| Message | Meaning | Resolution |
+--------------------------+-------------------------------+--------------------------------+
| Invalid arguments to ... | The job is not a hash | Pass an expanded job |
| | reference | |
+--------------------------+-------------------------------+--------------------------------+
PSEUDOCODE
validate the job
if the job names a container image, resolve its matrix values and return it
if a setup-perl step chose a version, return the Perl image for it
return the image the runner implies
LIMITATIONS
A matrix whose dimension is an expression, such as
perl: ${{ fromJSON(needs.setup.outputs.versions) }}, is expanded as the single literal string, because job outputs do not exist before the run.Job display names are
id (key=value, ...), which is clearer in a terminal but not what the GitHub web interface shows (id (value, ...)).includefollows GitHub's rule for the matrix's own keys only: an include whose added key conflicts with a key added by an earlier include overwrites it rather than forming a new combination.runs-onrunner groups (a mapping withgroupandlabels) are rejected, and an ARM runner such asubuntu-24.04-armis given theubuntu:24.04image without a platform, so it runs on the host's architecture.On Perl older than 5.36 a boolean matrix value is indistinguishable from 1 or the empty string; see "LIMITATIONS" in App::Actions::Util.
The object holds references to its parsed data; the accessors return them without copying, so a caller can change the workflow it was given.
COMMON PITFALLS
jobsandexpand_jobsare different. "jobs" has one entry per job in the file. "expand_jobs" has one entry per matrix combination.Do not change what the accessors return. They return the object's own data, so a change affects later calls. Copy first.
Display names are not GitHub's. This module writes
test (perl=5.40); GitHub showstest (5.40). Match jobs bybase_id, not by display name.Booleans in a matrix need Perl 5.36. On older Perls, a matrix value of
truebecomes1.A matrix from an expression is not expanded.
${{ fromJSON(...) }}is kept as one literal value, because the value it needs does not exist before the run.runs-onmust be text or a list. A runner group (a mapping withgroupandlabels) is refused withworkflow.runs_on_invalid.A non-Ubuntu runner gets the fallback image.
macos-latestorwindows-latestare not emulated; "is_linux_runner" tells you which jobs to skip.container.imagemay still hold expressions. "container_image" resolvesmatrix.*only.github.*andenv.*are left for the caller.
ENCODING
The workflow file is read as UTF-8 (see "ENCODING" in App::Actions::Util). Names, step commands and matrix values may contain any Unicode text, including non-ASCII characters and emoji, and are returned as Perl character strings. Job ids should be ASCII letters, digits, - and _, as GitHub requires. Image names are ASCII, as Docker requires. The file name you pass is used as bytes.
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
┌─ Load ────────────────────────────────────────────
│ file? : PATH
│ wf! : Workflow
├───────────────────────────────────────────────────
│ let doc == LoadYamlMapping(file?, workflow)
│ doc.jobs ¬in; MAPPING ⇒ raises NoJobs(file?)
│ ∀ id : dom doc.jobs •
│ doc.jobs(id) ∈ MAPPING ∧ doc.jobs(id).steps ∈ seq VALUE
│ wf!.file = file?
│ wf!.name = (doc.name ≠ ⊥ ⇒ doc.name) ∧ (doc.name = ⊥ ⇒ basename(file?))
│ #wf!.jobs = #(dom doc.jobs)
│ ∀ j : ran wf!.jobs • j.needs ∈ seq STRING
└───────────────────────────────────────────────────
file, name, env, defaults, jobs, raw
┌─ Accessor ────────────────────────────────────────
│ ΞWorkflow
│ field? : {file, name, env, defaults, jobs, raw}
│ value! : VALUE
├───────────────────────────────────────────────────
│ value! = θWorkflow.field?
└───────────────────────────────────────────────────
job_by_id
┌─ JobById ─────────────────────────────────────────
│ ΞWorkflow
│ id? : STRING
│ job! : optional Job
├───────────────────────────────────────────────────
│ (∃ j : ran jobs • j.id = id?) ⇒ job! = first {j : ran jobs | j.id = id?}
│ (∄ j : ran jobs • j.id = id?) ⇒ job! = ⊥
└───────────────────────────────────────────────────
expand_jobs
COMBINATION == KEY ⇸ VALUE
┌─ ExpandJobs ──────────────────────────────────────
│ ΞWorkflow
│ out! : seq Instance
├───────────────────────────────────────────────────
│ out! = ⁀/ ⟨ j : jobs • ⟨ i : 1 .. #C(j) • instance(j, C(j)(i), i-1, #C(j)) ⟩ ⟩
│ where C(j) = ExpandMatrix(j.strategy.matrix)
│ ∀ o : ran out! • 0 ≤ o.matrix_index < o.matrix_total
└───────────────────────────────────────────────────
┌─ ExpandMatrix ────────────────────────────────────
│ m? : optional (KEY ⇸ VALUE)
│ cs! : seq COMBINATION
├───────────────────────────────────────────────────
│ m? = ⊥ ⇒ cs! = ⟨∅⟩
│ let K == dom m? \ {include, exclude}
│ K = ∅ ⇒ cs! = m?.include (or ⟨∅⟩ when that is empty)
│ K ≠ ∅ ⇒ cs! = (product(K) ▷ ¬excluded) ⊕ includes
│ ∀ k : K • #m?(k) ≥ 1
└───────────────────────────────────────────────────
runs_on_text
┌─ RunsOnText ──────────────────────────────────────
│ runsOn? : optional (STRING ∪ seq STRING)
│ matrix? : optional COMBINATION
│ text! : STRING
├───────────────────────────────────────────────────
│ runsOn? ∈ MAPPING ⇒ raises RunsOnInvalid
│ runsOn? = ⊥ ⇒ text! = ⟨⟩
│ runsOn? ∈ STRING ⇒ text! = interpolate(runsOn?, matrix?)
│ runsOn? ∈ seq STRING ⇒ text! = interpolate(join(" ", runsOn?), matrix?)
└───────────────────────────────────────────────────
is_linux_runner
┌─ IsLinuxRunner ───────────────────────────────────
│ runsOn? : optional STRING
│ linux! : {0, 1}
├───────────────────────────────────────────────────
│ linux! = 0 ⇔ (∃ w : {windows, macos, darwin} • w is a word of lower(runsOn?))
└───────────────────────────────────────────────────
default_image_for_runner
┌─ DefaultImageForRunner ───────────────────────────
│ runsOn? : optional STRING
│ fallback? : optional STRING
│ image! : optional STRING
├───────────────────────────────────────────────────
│ "ubuntu-" ⁀ v ⊆ runsOn? ∧ v ∈ VERSION ⇒ image! = "ubuntu:" ⁀ v
│ "ubuntu-latest" ⊆ runsOn? ⇒ image! = "ubuntu:latest"
│ otherwise image! = fallback?
└───────────────────────────────────────────────────
perl_image_for_version
┌─ PerlImageForVersion ─────────────────────────────
│ version? : optional STRING
│ template? : optional STRING
│ image! : STRING
├───────────────────────────────────────────────────
│ let t == (template? ∈ {⊥, ⟨⟩} ⇒ "perl:%s") ∧ (otherwise ⇒ template?)
│ let v == (version? ∈ {⊥, ⟨⟩, dev, latest} ⇒ "latest")
│ ∧ (otherwise ⇒ version? without a leading v)
│ "%s" ⊆ t ⇒ image! = t with every "%s" replaced by v
│ "%s" ⊈ t ⇒ image! = t ⁀ ":" ⁀ v
└───────────────────────────────────────────────────
container_image
┌─ ContainerImage ──────────────────────────────────
│ job? : Instance
│ runsOn? : optional STRING
│ fallback? : optional STRING
│ image! : optional STRING
├───────────────────────────────────────────────────
│ job?.container.image ≠ ⊥
│ ⇒ image! = interpolateKnown(job?.container.image, job?.matrix)
│ job?.container.image = ⊥ ∧ job?.setup_perl_version ¬in; {⊥, ⟨⟩}
│ ⇒ image! = PerlImageForVersion(job?.setup_perl_version, template?)
│ otherwise image! = DefaultImageForRunner(runsOn?, fallback?)
└───────────────────────────────────────────────────
STATE DIAGRAM
A workflow object never changes after "load". This is the path from a file to the jobs the runner starts:
load($file)
|
v
+---------------------+ file / YAML / mapping checks
| READ FILE | -> workflow.file_required, file_missing,
+---------------------+ parse_failed, empty, not_mapping
|
v
+---------------------+ jobs not a mapping -> workflow.no_jobs
| CHECK EACH JOB | job not a mapping -> workflow.job_not_mapping
| (in file order) | no steps list -> workflow.job_no_steps
| | bad container / services / runs-on / env /
| | defaults / strategy -> workflow.* codes
| | each step: App::Actions::Util::normalize_step
+---------------------+
|
v
+---------------------+
| LOADED (immutable) | file, name, env, defaults, jobs, raw,
+---------------------+ job_by_id
|
| expand_jobs
v
+---------------------+ matrix not a mapping -> workflow.matrix_not_mapping
| EXPAND MATRIX | matrix with no values -> workflow.matrix_empty
| cross product, | bad include / exclude -> workflow.include_invalid,
| - exclude, | workflow.exclude_invalid
| + include |
+---------------------+
|
v
+---------------------+ each has base_id, matrix, matrix_index,
| JOB INSTANCES | matrix_total, display_name,
+---------------------+ setup_perl_version
|
| runs_on_text, is_linux_runner
v
Linux runner? --no--> the runner skips the job
| yes
v
container_image:
container.image? --yes--> that image, matrix.* filled in
| no
setup-perl step? --yes--> perl_image_for_version(version)
| no
default_image_for_runner(runs-on, fallback)
|
v
image for the runner (App::Actions::Runner)
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.