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 include and exclude as GitHub does, and gives each one a display name such as test (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 matches runs-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 - the runs-on value: 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

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 as 5.38 or v5.40; undef, empty, dev and latest all mean the newest.

  • $template - optional; the image name with %s where the version goes, or a repository name to which :VERSION is appended. Defaults to perl:%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

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, ...)).

  • include follows 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-on runner groups (a mapping with group and labels) are rejected, and an ARM runner such as ubuntu-24.04-arm is given the ubuntu:24.04 image 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

  • jobs and expand_jobs are 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 shows test (5.40). Match jobs by base_id, not by display name.

  • Booleans in a matrix need Perl 5.36. On older Perls, a matrix value of true becomes 1.

  • 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-on must be text or a list. A runner group (a mapping with group and labels) is refused with workflow.runs_on_invalid.

  • A non-Ubuntu runner gets the fallback image. macos-latest or windows-latest are not emulated; "is_linux_runner" tells you which jobs to skip.

  • container.image may still hold expressions. "container_image" resolves matrix.* only. github.* and env.* 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.