workflows

Read a repository’s GitHub Actions workflows, follow their runs, and add new ones.

GitHub Actions runs the YAML files in a repository’s .github/workflows folder. Each file is a workflow: it says which events start it (on) and which jobs to run, and a job can wait for others through needs. This module reads those files into rows a user interface can draw, talks to GitHub about runs and secrets through gheasy, and writes new workflows from gheasy’s recipes.


source

WorkflowError

def WorkflowError(
    *args, **kwargs
):

Unspecified run-time error.

WorkflowError is the only exception this module raises. Reading local files never raises it: parse puts the reason on the row’s error and returns the row. Writing a file, calling GitHub without gheasy or a token, starting a run and pushing a secret do raise it.


source

gheasy_core

def gheasy_core():

Return the gheasy.core module, raising WorkflowError with the install hint when gheasy is missing.

gheasy_core imports gheasy for every function that needs it, and is public for hosts that drive the same module; _gheasy remains as an alias. When gheasy is missing, the call fails with a WorkflowError that names the install command. _opened finds a gheasy helper under its public name or under the underscored name that older gheasy releases used.


source

levels

def levels(
    jobs
):

Arrange jobs into rows, each one row below its deepest dependency, with any cycle in a last row.

PyYAML follows YAML 1.1, which reads the bare key on as the boolean True, so a workflow’s triggers end up under the wrong key. _yaml uses gheasy’s YAML 1.2 parser when gheasy is installed, which keeps on as a string, and otherwise wraps PyYAML. parse accepts both. The example shows the two parsers side by side:

list(_yaml().load('on: push')), list(yaml.safe_load('on: push'))
(['on'], [True])

levels arranges jobs into rows for drawing a graph. Jobs with no dependencies go in the first row, and every other job goes one row below its deepest dependency. A needs entry that names a job not in the list is ignored. Jobs caught in a dependency cycle cannot be placed this way, so they all go together in one last row instead of raising an error.

jobs = [{'id': 'lint', 'needs': []}, {'id': 'test', 'needs': []},
        {'id': 'ship', 'needs': ['lint', 'test']}, {'id': 'notify', 'needs': ['ship']}]
levels(jobs)
[['lint', 'test'], ['ship'], ['notify']]

source

Workflows

def Workflows(
    root
):

The workflows of one repository: local files, runs on GitHub, the catalogue, and secrets.

Workflows covers one repository: its local workflow files, their runs on GitHub, the catalogue of workflows pullup can add, and the secrets and variables those workflows read. The examples use this workflow, which has three triggers and a ship job that waits for lint and test:

WF = r'''name: CI
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 4 * * 1'
  workflow_dispatch:
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ruff check .
  test:
    name: pytest
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: run the suite
        run: |
          uv sync
          pytest -q
  ship:
    needs: [lint, test]
    if: github.ref == 'refs/heads/main'
    environment: pypi
    runs-on: ubuntu-latest
    steps:
      - run: echo ship
'''

files returns the .yml and .yaml files in .github/workflows, sorted by name. Other files in the folder are ignored, as GitHub ignores them. A repository without the folder gives [], not an error.

w = Workflows(repo)
[p.name for p in w.files()]
['ci.yml', 'docs.yml']

parse turns one file into a row: the workflow’s name, its triggers, its jobs, their levels, and whether it can be started by hand (dispatchable, true when it declares workflow_dispatch). A file that cannot be read or is not valid YAML still gives a row, with the reason in error. parsed does this for every file:

row = w.parse(wfs/'ci.yml')
row['name'], row['dispatchable'], row['levels']
('CI', True, [['lint', 'test'], ['ship']])
[(r['name'], r['dispatchable'], r['error']) for r in w.parsed()]
[('CI', True, ''), ('Docs', True, '')]

_triggers turns the on block into a list of event and detail rows. GitHub allows three shapes for on: a single event name, a list of names, or a mapping from each event to its filters, such as branches or a cron schedule. All three come out in the same form:

row['triggers']
[{'event': 'push', 'detail': 'branches: main'},
 {'event': 'schedule', 'detail': '0 4 * * 1'},
 {'event': 'workflow_dispatch', 'detail': ''}]

_job turns one entry under jobs into a row. needs may be one name or a list, and environment may be a name or a mapping with a name; both come out in one form. Each step is labelled by its name, or else its uses, or else the first line of its run:

[(j['id'], j['name'], j['needs'], j['environment']) for j in row['jobs']]
[('lint', 'lint', [], ''),
 ('test', 'pytest', [], ''),
 ('ship', 'ship', ['lint', 'test'], 'pypi')]
[(s['name'], s['uses']) for s in row['jobs'][1]['steps']]
[('actions/checkout@v4', 'actions/checkout@v4'), ('run the suite', '')]

Everything so far reads local files and uses no network. The methods that follow call GitHub through gheasy and need a token in GITHUB_TOKEN. status says which of gheasy, a GitHub remote and the token is missing. runs lists recent runs, run_jobs lists the jobs and steps of one run, dispatch starts a workflow that declares workflow_dispatch, and cancel stops a run. remote_env lists the secrets and variables already on GitHub; GitHub returns only the names of secrets, never their values.

_fastship loads gheasy’s fastship release recipe, which gheasy gained in 0.0.8. With an older gheasy it raises WorkflowError asking you to upgrade.

_gh_authed runs gh auth status, the only way to find out whether the GitHub CLI is logged in. A missing gh, a timeout or a non-zero exit all count as not logged in.

_wf_name is the check every write goes through. A name with a directory in it, or with any suffix other than .yml or .yaml, is refused. Nothing outside .github/workflows is written by this module.

test_fail(lambda: _wf_name('.github/workflows/ci.yml'), contains='not a workflow file name')
test_fail(lambda: _wf_name('Makefile'), contains='not a workflow file name')
test_eq(_wf_name('ci.yaml'), 'ci.yaml')
test_eq(_from_line('# built by dockeasy\nFROM python:3.12-slim\nRUN uv sync'), 'FROM python:3.12-slim')
test_eq(_from_line(''), '')
test_eq(_read(repo/'nowhere.toml'), '')

_run_row turns one run from GitHub’s API into a flat row for display. The title is the run’s display title, or else the first line of the head commit’s message:

run = {'id': 178, 'name': 'CI', 'path': '.github/workflows/ci.yml', 'run_number': 12,
       'status': 'completed', 'conclusion': 'failure', 'head_branch': 'main', 'event': 'push',
       'html_url': 'https://github.com/o/r/actions/runs/178', 'actor': {'login': 'jph'},
       'head_commit': {'message': 'drop the retry\n\nit never fired'}}
_run_row(run)
{'id': 178,
 'name': 'CI',
 'file': 'ci.yml',
 'number': 12,
 'status': 'completed',
 'conclusion': 'failure',
 'branch': 'main',
 'event': 'push',
 'created_at': '',
 'updated_at': '',
 'url': 'https://github.com/o/r/actions/runs/178',
 'actor': 'jph',
 'title': 'drop the retry'}

detect describes the project from its working tree. Each flag is true when a file exists or a name appears in pyproject.toml or requirements.txt: web looks for FastHTML, FastAPI, Django or Flask, vps for vpseasy, cfeasy or hcloud, and publish for a build backend. build is the FROM line of the Dockerfile dockeasy would write, and build_why says why it is empty: dockeasy is missing, or its detect_app raised.

(repo/'pyproject.toml').write_text('[project]\nname = "demo"\ndependencies = ["python-fasthtml"]\n'
                                   '[build-system]\nrequires = ["hatchling"]\n')
det = w.detect()
{k: v for k, v in det.items() if v}
{'python': True,
 'fastship': True,
 'web': True,
 'publish': True,
 'build': 'FROM ghcr.io/astral-sh/uv:python3.13-bookworm AS builder'}

catalogue lists the seven workflows pullup can add, all built from gheasy’s recipes. For each one it says whether this project wants it (recommended, with the reason in why) and whether a file of that name already exists (present). The example project is a FastHTML package built with hatchling, so Python CI, a fastship release and a Docker image are recommended. Both CI entries show as present because they write ci.yml, which the example repository already has:

[(r['id'], r['recommended'], r['present'], r['why']) for r in w.catalogue()['workflows']]
[('ci', True, True, 'a Python project'),
 ('nbdev', False, True, ''),
 ('pypi', False, False, ''),
 ('fastship', True, False, 'a Python package that is not nbdev'),
 ('docker', True, False, 'there is an image to build'),
 ('deploy', False, False, ''),
 ('pages', False, False, '')]

The rest of the class acts on the catalogue and on the project’s settings. preview returns the YAML an entry would write, and add writes it into .github/workflows, refusing to replace a file that exists. generate and write_generated do the same for the one workflow gheasy builds from .gheasy/config.json. build turns a spec from the workflow builder (see wfbuild) into YAML, write_spec writes that YAML and keeps the spec beside it, and spec reads the spec back and says whether the file still matches it. docker previews the Dockerfile dockeasy detects and write_dockerfile writes it. schema returns gheasy’s env_schema, and push_env sends values for the declared keys to GitHub through the gh CLI, skipping keys the schema does not declare or that have no value.