GitHub Actions Workflow Files
pyTooling.CI.GitHub.WorkflowFile models a GitHub Actions workflow file - the YAML file below
.github/workflows, not a run of it (that’s GitHub Actions):
from pathlib import Path
from pyTooling.CI.GitHub.WorkflowFile import Workflow
workflow = Workflow.FromFile(Path(".github/workflows/CompletePipeline.yml"))
print(f"{workflow.Name}: {len(workflow.Inputs)} inputs, {workflow.JobCount} jobs")
for job in workflow.IterateJobs():
print(f" {job.Name:<24} {job.Uses or job.RunsOn} needs {', '.join(job.NeedNames)}")
The file is read with ruamel.yaml, so the module needs the github extra - see Dependencies.
The Tree
Workflow a workflow file
+-- Input an input of 'on.workflow_call'
+-- Output an output of 'on.workflow_call'
+-- Secret a secret of 'on.workflow_call'
+-- Permission a permission the workflow declares
+-- Job a job, in file order
+-- UsesReference the reusable workflow the job calls
+-- Permission a permission the job declares
+-- Matrix the job's 'strategy.matrix'
+-- Step a step of the job
+-- UsesReference the action the step runs
Action an action's file, 'action.yml'
+-- Step a step of a composite action
+-- UsesReference the action the step runs
A workflow is named by its file’s stem -
CompletePipeline- because a caller names it that way inuses. Thenamekey isDisplayName.An input keeps the type its default is written with -
'3.14'is a string,falsea boolean - and a multi-line default keeps its line breaks.A job either runs steps on
RunsOn, or calls the reusable workflow inUses.UsesReferencetakes a reference apart intoRepository,PathandReference:pyTooling/Actions/.github/workflows/Package.yml@r8 repository, path and ref ./.github/workflows/Package.yml a file of the same repository and commit
Matrix.IsDynamicsays whether a matrix’ instances are known at run time only, as forinclude: ${{ fromJson(inputs.jobs) }}.A job’s
ContainerandServicesare the images of the containers it runs in.An action is named by its directory -
ComputeRequirementsfor.github/actions/ComputeRequirements/action.yml. Of a composite action, the steps are read; of a Docker action, theImage.Expressions -
if,runs-on: ${{ matrix.runs-on }}, an output’svalue- are kept as written and are not evaluated.
Source Lines
Every element knows the file it was read from - a workflow’s or an action’s - and the line it starts at, so a message can say where a finding comes from:
job = workflow.Jobs["PublishOnPyPI"]
print(f"{job.Location}: job '{job.Name}' has a condition") # CompletePipeline.yml:532: ...
A file that is not a well-formed workflow - a job needing a job the workflow doesn’t have, jobs needing each other in a
cycle, an input without type - raises WorkflowError. It carries the file
and the line in Path and
Line, and names both in a note.
Dependencies Between Jobs
Job.Needs resolves the names of needs to the jobs. The pipeline
they form is built by ToPipeline() (see Building a Pipeline)
and converted into a Graph by ToGraph(), whose edges read
needs, and which by default drops a dependency a longer path already implies:
Package needs Prepare Local --> Package --> Prepare
Local needs Prepare, Package
(Local --> Prepare is implied)
Called Workflows
A job calling a reusable workflow names it by repository, path and ref.
WorkflowResolver reads the called file from a local directory, mapped per
repository, whatever the ref:
from pyTooling.CI.GitHub.WorkflowFile import WorkflowResolver
resolver = WorkflowResolver({"pyTooling/Actions": Path(".github/workflows")})
pipeline = resolver.Load(Path(".github/workflows/CompletePipeline.yml"))
for job in pipeline.IterateJobs():
if job.Uses is not None and (called := resolver.Resolve(job.Uses)) is not None:
print(f"{job.Name} calls {called.Name} with {called.JobCount} jobs")
A local reference -
./.github/workflows/Package.yml- is read from the directory of the calling workflow.A repository without a directory answers
None: its files are not fetched.Every file is read once; resolving it again returns the same
Workflow.
ResolveAction() reads the action a step runs the same way, from
its action.yml, so the actions a composite action runs in turn are known:
An action of a mapped repository -
pyTooling/Actions/.github/actions/ComputeRequirements@r8- is read from the repository’s root, the directory holding the.githubdirectory the mapped directory is in.A local action -
./.github/actions/ComputeRequirements- is read from the root of the repository of the calling workflow or action.
Permissions
A called workflow can keep or reduce the permissions of the GITHUB_TOKEN, never raise them, so what its jobs declare
is what a caller has to grant. CollectPermissions() collects the
permissions a workflow and its jobs declare, and - given a resolver - those of the workflows they call:
for scope, permission in pipeline.CollectPermissions(resolver).items():
print(f"{permission} asked for at {permission.Location}")
# contents: write asked for at CompletePipeline.yml:500
# pages: write asked for at PublishToGitHubPages.yml:67
When several elements declare a scope, the permission granting the most access is returned, so its location names where that access is asked for.
Building a Pipeline
ToPipeline() builds the pipeline a workflow defines as a
pyTooling.CI model (Pipeline), expanding the workflows its jobs call as far as a resolver
reads them and depth allows:
pipeline = workflow.ToPipeline(resolver, depth=1)
graph = pipeline.ToGraph() # transitively reduced
for vertex in graph.IterateTopologically(): # in an order the jobs can run in
element = vertex.Value
print(f"{element.QualifiedName} {element.Definition.Location}")
Job in the workflow file |
Element of the pipeline |
|---|---|
with |
|
with |
|
with |
|
|
|
|
An element is named by its job’s key, as the file names it in
needs. A step is named as GitHub displays it: by itsname, orRunfollowed by its action or the first line of its script.Every element links to what it was built from:
Definitionis theJob- with its line, itsusesreference and its permissions - or theStep, and for the pipeline theWorkflow. A called workflow’sCalledWorkflowis the file it was expanded from.A matrix yields its combinations as GitHub computes them from its dimensions,
excludeandinclude-Matrix.Combinations-, and an instance is named by its values:Test (ubuntu, 3.14). ItsDimensionsare the combination, the values formatted as GitHub prints them:{"os": "ubuntu", "python": "3.14"}. A dynamic matrix -include: ${{ fromJson(...) }}- is aDefinedMatrixwithout instances, since its combinations are known at run time only.A workflow calling itself, directly or through others, raises
WorkflowError.
Linking a Run
A run read from the GitHub REST API (GitHub Actions) has no needs: the API doesn’t report them.
ApplyNeeds() gives a run the dependencies its workflow file - named by
Pipeline.Path - declares:
run = Pipeline.FromJSON(runJSON, jobsJSON)
workflow = resolver.Load(Path(run.Path))
for name in workflow.ApplyNeeds(run, resolver):
print(f"Job '{name}' isn't in the run.")
graph = run.ToGraph()
A job is looked up in the run by its display name - its
name-, or else by its key:group.GetElement(name). A matrix is found as theMatrixits instances were grouped into.A job calling a reusable workflow is followed into the run’s called workflow, and a matrix of calls into each instance, as far as the resolver reads the called file.
A run names an instance’s dimensions by position -
{"0": "ubuntu", "1": "3.14"}-, since a job’s name carries the values only. For a static matrix, an instance whose values are those of a combination gets the combination’s names:{"os": "ubuntu", "python": "3.14"}. The instances of a dynamic matrix, and one matching no combination, keep the positions.A job named by an expression -
${{ matrix.os }} Tests- can’t be looked up and is skipped. A job with a condition may have been skipped in the run, so it isn’t reported when it’s missing. Every other job missing in the run is returned by its qualified name, as the run would name it:Local / Staticfor the jobStaticof the workflow the jobLocalcalls.
Competing Solutions
No package on PyPI reads a workflow file into a Python object model. The packages below check a workflow file, or write one.
Outside Python, zizmor has typed models of workflows, actions and Dependabot files - the
Rust crate github-actions-models - and checks a workflow for
security problems, as a template injection or an unpinned action. actionlint, written in Go,
checks a file’s syntax and expressions, the needs of its jobs and the inputs of the reusable workflows it calls.
Both are installed from PyPI as command line tools, so a Python program gets their findings, not a model.
JSON Schema
Source: the schemas of SchemaStore, checked by check-jsonschema.
Disadvantages
A file is validated against the schema. A program reading it gets nested dictionaries and lists.
Classes generated from the schema - e.g. by datamodel-code-generator - are typed, but know neither the line of an element nor its parent, and don’t check the
needsof a job.
Advantages
The schema is used by editors too, so a file is checked the same way while it is written.
Workflow Generators
Source: github-actions-cdk, pygha.
Disadvantages
They write a workflow file from Python code, but don’t read one.