# workflows


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

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.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/pullup/blob/main/pullup/workflows.py#L28"
target="_blank" style="float:right; font-size:smaller">source</a>

### WorkflowError

``` python
def WorkflowError(
    *args, **kwargs
):
```

*Unspecified run-time error.*

[`WorkflowError`](https://vedicreader.github.io/pullup/workflows.html#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.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/pullup/blob/main/pullup/workflows.py#L32"
target="_blank" style="float:right; font-size:smaller">source</a>

### gheasy_core

``` python
def gheasy_core():
```

*Return the `gheasy.core` module, raising
[`WorkflowError`](https://vedicreader.github.io/pullup/workflows.html#workflowerror)
with the install hint when gheasy is missing.*

[`gheasy_core`](https://vedicreader.github.io/pullup/workflows.html#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`](https://vedicreader.github.io/pullup/workflows.html#workflowerror)
that names the install command.
[`_opened`](https://vedicreader.github.io/pullup/workflows.html#_opened)
finds a gheasy helper under its public name or under the underscored
name that older gheasy releases used.

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/pullup/blob/main/pullup/workflows.py#L61"
target="_blank" style="float:right; font-size:smaller">source</a>

### levels

``` python
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`](https://vedicreader.github.io/pullup/workflows.html#_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:

``` python
list(_yaml().load('on: push')), list(yaml.safe_load('on: push'))
```

    (['on'], [True])

[`levels`](https://vedicreader.github.io/pullup/workflows.html#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.

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

    [['lint', 'test'], ['ship'], ['notify']]

------------------------------------------------------------------------

<a
href="https://github.com/vedicreader/pullup/blob/main/pullup/workflows.py#L81"
target="_blank" style="float:right; font-size:smaller">source</a>

### Workflows

``` python
def Workflows(
    root
):
```

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

[`Workflows`](https://vedicreader.github.io/pullup/workflows.html#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`:

``` python
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.

``` python
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`](https://vedicreader.github.io/pullup/workflows.html#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:

``` python
row = w.parse(wfs/'ci.yml')
row['name'], row['dispatchable'], row['levels']
```

    ('CI', True, [['lint', 'test'], ['ship']])

``` python
[(r['name'], r['dispatchable'], r['error']) for r in w.parsed()]
```

    [('CI', True, ''), ('Docs', True, '')]

[`_triggers`](https://vedicreader.github.io/pullup/wfbuild.html#_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:

``` python
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`:

``` python
[(j['id'], j['name'], j['needs'], j['environment']) for j in row['jobs']]
```

    [('lint', 'lint', [], ''),
     ('test', 'pytest', [], ''),
     ('ship', 'ship', ['lint', 'test'], 'pypi')]

``` python
[(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`](https://vedicreader.github.io/pullup/workflows.html#_fastship)
loads gheasy’s fastship release recipe, which gheasy gained in 0.0.8.
With an older gheasy it raises
[`WorkflowError`](https://vedicreader.github.io/pullup/workflows.html#workflowerror)
asking you to upgrade.

[`_gh_authed`](https://vedicreader.github.io/pullup/workflows.html#_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`](https://vedicreader.github.io/pullup/workflows.html#_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.

``` python
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`](https://vedicreader.github.io/pullup/workflows.html#_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:

``` python
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.

``` python
(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:

``` python
[(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.
