# wfbuild


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

Writing GitHub Actions YAML by hand is easy to get wrong. This module
builds a workflow from a spec, a small dictionary of choices that a form
in a user interface can fill in: which events start the workflow, and
which preset jobs it runs.
[`compose`](https://vedicreader.github.io/pullup/wfbuild.html#compose)
turns the spec into YAML with gheasy’s workflow builder, and
[`save_spec`](https://vedicreader.github.io/pullup/wfbuild.html#save_spec)
keeps the spec so the workflow can be edited later through the same
form.

A spec looks like this:

``` python
{'name': 'ci', 'on': {'push': 'main', 'pull_request': 'main'},
 'jobs': [{'preset': 'uv_lint'},
          {'preset': 'uv_test', 'needs': 'lint', 'options': {'test_cmd': 'pytest -q'}}]}
```

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

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

### spec_dir

``` python
def spec_dir():
```

*The directory, relative to the repository root, where workflow specs
are kept.*

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

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

### use_spec_dir

``` python
def use_spec_dir(
    dir
):
```

*Set the directory, relative to the repository root, where workflow
specs are kept.*

Specs are kept as JSON files in one directory of the repository:
`workflows/`, unless a host application picks another with
[`use_spec_dir`](https://vedicreader.github.io/pullup/wfbuild.html#use_spec_dir).
[`spec_dir`](https://vedicreader.github.io/pullup/wfbuild.html#spec_dir)
returns the directory in use. The YAML itself goes in
`.github/workflows/` as usual.

`TRIGGERS` lists the events the `on` part of a spec can name. Each has a
label, the kind of value a form should ask for (branch names, a
yes-or-no flag, or a cron expression) and a default. `PRESETS` lists the
job types a spec can use. Each preset has an id, the job name it
creates, a description and the options it accepts.
[`preset_rows`](https://vedicreader.github.io/pullup/wfbuild.html#preset_rows)
returns both lists for a user interface to render:

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

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

### preset_rows

``` python
def preset_rows():
```

*The presets and triggers a workflow-builder form offers.*

``` python
rows = preset_rows()
[p['id'] for p in rows['presets']], [t['id'] for t in rows['triggers']]
```

    (['uv_test',
      'uv_lint',
      'nbdev_test',
      'uv_pypi',
      'fastship',
      'docker',
      'node',
      'rust',
      'go',
      'deploy',
      'run'],
     ['push', 'tags', 'pull_request', 'workflow_dispatch', 'release', 'schedule'])

Form fields arrive as text or as lists.
[`_listy`](https://vedicreader.github.io/pullup/wfbuild.html#_listy)
splits a comma-separated value into a clean list,
[`_lines`](https://vedicreader.github.io/pullup/wfbuild.html#_lines)
splits a multi-line value into its non-blank lines, and
[`_branches`](https://vedicreader.github.io/pullup/wfbuild.html#_branches)
reads a branch field, treating `True` or an empty value as `main`:

``` python
_listy('main, dev ,'), _lines('echo one\n\n  echo two'), _branches(True)
```

    (['main', 'dev'], ['echo one', '  echo two'], ['main'])

[`_triggers`](https://vedicreader.github.io/pullup/wfbuild.html#_triggers)
applies the `on` part of a spec to a gheasy workflow. Branch fields
become `branches` filters, a `tags` value becomes a tag filter on the
push trigger (`v*` when it is just `True`), and a schedule becomes a
cron entry. A spec that turns on no trigger at all gets
`workflow_dispatch`, so the workflow can at least be started by hand.
The examples use `on_of`, a helper defined at the top of this notebook,
which applies the triggers to an empty workflow and returns the rendered
`on` block:

``` python
on_of({'push': 'main, dev', 'tags': 'v*'})
```

    {'push': {'branches': ['main', 'dev'], 'tags': ['v*']}}

With a single trigger and no filters, gheasy writes `on` as a bare
string instead of a mapping. That is the plain-string shape that
[`Workflows._triggers`](https://vedicreader.github.io/pullup/workflows.html#workflows._triggers)
reads back.

``` python
on_of({'push': False, 'workflow_dispatch': False})
```

    'workflow_dispatch'

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

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

### deploy_job

``` python
def deploy_job(
    wfb, job_id, opts, schema, app
):
```

*Add the lego deploy job: the schema as the job’s environment, the SSH
key, then the deploy command.*

[`deploy_job`](https://vedicreader.github.io/pullup/wfbuild.html#deploy_job)
adds the deploy job of the lego pattern. Every key in the schema becomes
an environment variable of the job: a key whose schema value is `None`
reads from GitHub `secrets`, and any other key reads from `vars`.
`DEPLOY_KEY` is always added as a secret. The job checks out the code
(with Git LFS files unless `lfs` is off), installs uv and the
dependencies, writes the SSH key when there is one, and runs the deploy
command:

``` python
wfb = Workflow('deploy')
deploy_job(wfb, 'deploy', {'cmd': 'python deploy.py deploy'}, {'API_KEY': None, 'REGION': 'eu'}, 'lego')
built(wfb)['jobs']['deploy']['env']
```

    {'API_KEY': '${{ secrets.API_KEY }}',
     'REGION': '${{ vars.REGION }}',
     'DEPLOY_KEY': '${{ secrets.DEPLOY_KEY }}'}

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

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

### fastship_job

``` python
def fastship_job(
    wfb, job_id:str='release', needs:NoneType=None
):
```

*Add gheasy’s fastship release job, which runs when fastship pushes a
version tag.*

[`fastship_job`](https://vedicreader.github.io/pullup/wfbuild.html#fastship_job)
adds the release job that runs when fastship pushes a version tag. The
job itself comes from gheasy. A gheasy without `fastship_release_job`
raises `AttributeError` with the upgrade command, instead of building a
workflow that has no release job.

``` python
wfb = Workflow('release')
if hasattr(wfb, 'fastship_release_job'):
    fastship_job(wfb)
    test_eq(list(built(wfb)['jobs']), ['release'])
else: test_fail(lambda: fastship_job(wfb), contains='pip install -U gheasy')
```

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

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

### nbdev_job

``` python
def nbdev_job(
    wfb, job_id:str='test', needs:NoneType=None
):
```

*Add nbdev’s CI job: install, run `nbdev-test`, then fail if
`nbdev-clean` changes a notebook.*

[`nbdev_job`](https://vedicreader.github.io/pullup/wfbuild.html#nbdev_job)
adds nbdev’s CI job. After checkout and installing with uv, it runs
`nbdev-test`, which executes the notebooks as tests. A step named
“Notebooks are clean” then runs `nbdev-clean` and
`git diff --exit-code`, which fails when cleaning changed a committed
notebook. nbdev 3 has no check-only flag for `nbdev-clean`.

``` python
wfb = Workflow('ci'); nbdev_job(wfb)
[s.get('run') or s['uses'] for s in built(wfb)['jobs']['test']['steps']]
```

    ['actions/checkout@v4',
     'astral-sh/setup-uv@v5',
     'uv sync --frozen',
     'uv run nbdev-test',
     'uv run nbdev-clean\ngit diff --exit-code']

The `run` preset is the catch-all. Give it commands, one per line, and
each becomes a step named `Step 1`, `Step 2` and so on. Blank lines are
dropped. With no commands it runs `echo hello`, because GitHub rejects a
job with no steps. With `install` off, the job keeps checkout and uv
setup but skips installing the dependencies:

``` python
wfb = Workflow('ci')
_run_job(wfb, 'smoke', {'cmds': 'echo one\n\necho two', 'install': False})
[(s['name'], s.get('run') or s['uses']) for s in built(wfb)['jobs']['smoke']['steps']]
```

    [('Checkout', 'actions/checkout@v4'),
     ('Setup uv', 'astral-sh/setup-uv@v5'),
     ('Step 1', 'echo one'),
     ('Step 2', 'echo two')]

gheasy’s preset jobs choose their own ids.
[`_adopt`](https://vedicreader.github.io/pullup/wfbuild.html#_adopt)
renames the job just added to the id the spec asked for and attaches its
`needs`, so that other jobs in the spec can refer to it.

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

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

### compose

``` python
def compose(
    spec, schema:NoneType=None, app:str=''
):
```

*Turn a workflow-builder spec into YAML with gheasy, giving each job a
unique id.*

[`compose`](https://vedicreader.github.io/pullup/wfbuild.html#compose)
turns a whole spec into YAML. It applies the triggers, then adds one job
for each row of `jobs` from that row’s preset, passing on its `options`
and `needs`. Rows that name an unknown preset are skipped. When two rows
would get the same job id, the second gets a number, so two `uv_test`
rows give `test` and `test2`.
[`compose`](https://vedicreader.github.io/pullup/wfbuild.html#compose)
reads and writes no files;
[`Workflows.build`](https://vedicreader.github.io/pullup/workflows.html#workflows.build)
is the method that calls it for a repository:

``` python
spec = {'name': 'ci', 'on': {'push': 'main', 'pull_request': 'main'},
    'jobs': [{'preset': 'uv_lint'},
        {'preset': 'uv_test', 'needs': 'lint',
            'options': {'test_cmd': 'pytest -q', 'python_versions': '3.11, 3.12'}}]}
print(compose(spec))
```

    name: ci
    on:
      push:
        branches:
          - main
      pull_request:
        branches:
          - main
    jobs:
      lint:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout
            uses: actions/checkout@v4
          - name: Setup uv
            uses: astral-sh/setup-uv@v5
          - name: Install dependencies
            run: uv sync --frozen
          - name: Lint
            run: uv run ruff check . && uv run ruff format --check .
      test:
        needs:
          - lint
        runs-on: ubuntu-latest
        strategy:
          matrix:
            python-version:
              - '3.11'
              - '3.12'
        steps:
          - name: Checkout
            uses: actions/checkout@v4
          - name: Setup uv
            uses: astral-sh/setup-uv@v5
          - name: Install dependencies
            run: uv sync --frozen
          - name: Test
            run: uv run pytest -q

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

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

### spec_path

``` python
def spec_path(
    root, file, dir:NoneType=None
):
```

*Where the spec for workflow `file` is kept.*

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

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

### load_spec

``` python
def load_spec(
    root, file, dir:NoneType=None
):
```

*The spec a workflow was built from, or None when there is no readable
one.*

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

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

### save_spec

``` python
def save_spec(
    root, file, spec, dir:NoneType=None
):
```

*Write the spec for workflow `file` as JSON and return its path.*

[`save_spec`](https://vedicreader.github.io/pullup/wfbuild.html#save_spec)
writes a spec as JSON to the path
[`spec_path`](https://vedicreader.github.io/pullup/wfbuild.html#spec_path)
gives, `<spec dir>/<workflow name>.json`, and
[`load_spec`](https://vedicreader.github.io/pullup/wfbuild.html#load_spec)
reads it back, returning None when the file is missing or is not valid
JSON.
[`Workflows.write_spec`](https://vedicreader.github.io/pullup/workflows.html#workflows.write_spec)
uses them to keep the spec for each workflow it writes:

``` python
tmp = TemporaryDirectory(); root = Path(tmp.name)
p = save_spec(root, 'ci.yml', spec)
p.relative_to(root), load_spec(root, 'ci.yml') == spec
```

    (Path('workflows/ci.json'), True)
