spec_dir
def spec_dir():The directory, relative to the repository root, where workflow specs are kept.
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 turns the spec into YAML with gheasy’s workflow builder, and save_spec keeps the spec so the workflow can be edited later through the same form.
A spec looks like this:
The directory, relative to the repository root, where workflow specs are kept.
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. 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 returns both lists for a user interface to render:
(['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 splits a comma-separated value into a clean list, _lines splits a multi-line value into its non-blank lines, and _branches reads a branch field, treating True or an empty value as main:
(['main', 'dev'], ['echo one', ' echo two'], ['main'])
_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:
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 reads back.
Add the lego deploy job: the schema as the job’s environment, the SSH key, then the deploy command.
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:
{'API_KEY': '${{ secrets.API_KEY }}',
'REGION': '${{ vars.REGION }}',
'DEPLOY_KEY': '${{ secrets.DEPLOY_KEY }}'}
Add gheasy’s fastship release job, which runs when fastship pushes a version tag.
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.
Add nbdev’s CI job: install, run nbdev-test, then fail if nbdev-clean changes a notebook.
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.
['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:
[('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 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.
Turn a workflow-builder spec into YAML with gheasy, giving each job a unique id.
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 reads and writes no files; Workflows.build is the method that calls it for a repository:
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
Where the spec for workflow file is kept.
The spec a workflow was built from, or None when there is no readable one.
Write the spec for workflow file as JSON and return its path.
save_spec writes a spec as JSON to the path spec_path gives, <spec dir>/<workflow name>.json, and load_spec reads it back, returning None when the file is missing or is not valid JSON. Workflows.write_spec uses them to keep the spec for each workflow it writes: