wads.ci_trigger¶
On-demand CI: render a repo’s caller stub from [tool.wads.ci.trigger], and flip
a repo to on-demand in one idempotent command.
[tool.wads.ci.trigger].mode decides whether CI runs at all:
"auto"(the default): every push and pull request, as wads always has. The stub renders byte-identical towads/data/github_ci_uv_stub.yml."on-demand": nothing runs unless asked – the commit subject carriesrun_ci_marker(default"[run ci]"), or someone starts aworkflow_dispatch.
Two gates, because workflow expressions cannot split a string:
The stub’s job-level
if:is a zero-cost pre-filter. An ordinary push schedules no runner at all, but the pre-filter can only ask whether the marker is anywhere in the commit message.The reusable workflow’s setup job extracts the subject line, and every other job gates on the marker being in that (the TRIGGER GATE note in
.github/workflows/uv-ci.yml). A marker quoted only in a squash-merged PR body passes gate 1, costs one setup job, and runs nothing else.
The flip (flip_to_on_demand(), CLI wads-migrate ci-on-demand) sets the mode
plus a cheap test matrix and re-renders the stub. Everything is computed in a shadow
copy of the repo first, so a dry run and a real run take the same path.
Functions
|
Classify a |
|
Commit exactly |
|
Flip a repo to on-demand CI: pyproject settings plus a re-rendered stub. |
|
Add a |
|
Render the caller stub for a trigger mode. |
|
The |
|
|
|
Return pyproject.toml |
|
Split what a stub holds beyond the template into |
|
The on-demand stub's job-level pre-filter, as a workflow expression. |
|
The |
|
The |
|
The |
|
Other workflow files in |
|
Which of |
|
Validate a (mode, marker) pair with the same rules the CI setup job applies. |
Classes
|
What |
- class wads.ci_trigger.FlipResult(repo, status='unchanged', changes=<factory>, notes=<factory>)[source]¶
Bases:
objectWhat
flip_to_on_demand()changed or, on a dry run, would change.statusis"changed","unchanged"(already on-demand) or"refused"(nothing written;notessays why).changesmaps a repo-relative path to its(old, new)text.
- wads.ci_trigger.classify_ci_workflow(text)[source]¶
Classify a
ci.yml:'missing','stub','inline-uv'or'other'.- Return type:
>>> classify_ci_workflow(None) 'missing' >>> classify_ci_workflow("uses: i2mint/wads/.github/workflows/uv-ci.yml@master") 'stub' >>> classify_ci_workflow("uses: i2mint/wads/actions/run-tests-uv@master") 'inline-uv' >>> classify_ci_workflow("uses: actions/setup-python@v5") 'other'
- wads.ci_trigger.commit_paths(repo, paths, *, message)[source]¶
Commit exactly
pathsand return the new commit’s short sha.- Return type:
- wads.ci_trigger.flip_to_on_demand(repo='.', *, python_versions=('3.12',), test_on_windows=False, run_ci_marker=None, workflow='.github/workflows/ci.yml', dry_run=False)[source]¶
Flip a repo to on-demand CI: pyproject settings plus a re-rendered stub.
a stub
ci.ymlis re-rendered keeping its pin andwith:inputs (secrets move to the named transport, aswads-migrate ci-to-stubdoes), or refused if it holds anything else a re-render would drop (extra jobs, custom triggers);an inline uv workflow becomes the stub, carrying its secret-backed env vars into
[tool.wads.ci.env]first (aswads-migrate ci-to-stubdoes);no
ci.yml: only pyproject changes (nothing runs on push anyway);anything else (2025/legacy/custom workflow) is refused and nothing is written, because it would ignore the setting and keep running on every push.
Idempotent: a second run reports
"unchanged"and writes nothing.- Return type:
- wads.ci_trigger.render_stub_inputs(stub, inputs)[source]¶
Add a
with:block (the reusable workflow’s inputs) under the stub’suses:.- Return type:
- wads.ci_trigger.render_stub_trigger(stub, *, mode='auto', run_ci_marker='[run ci]')[source]¶
Render the caller stub for a trigger mode.
"auto"returnsstubunchanged."on-demand"replaces the push/PR trigger with push +workflow_dispatch, puts the plain-language explanation above it, and adds the pre-filterif:to thecijob.- Return type:
- wads.ci_trigger.repo_pyproject_for(ci_path)[source]¶
The
pyproject.tomlof the repo holding workflow fileci_path, if any.Walks up from the workflow, stopping at the first directory that holds a
.gitso an enclosing project’s pyproject is never mistaken for this repo’s.
- wads.ci_trigger.run_ci_on_demand(repo='.', *, dry_run=False, python_versions=('3.12',), test_on_windows=False, run_ci_marker=None, workflow='.github/workflows/ci.yml', commit=False, out=None, err=None)[source]¶
wads-migrate ci-on-demand: flip, report, optionally commit. Returns an exit code.0changed or already on-demand,2refused (nothing written).- Return type:
- wads.ci_trigger.set_on_demand_in_pyproject(text, *, python_versions=('3.12',), test_on_windows=False, run_ci_marker=None)[source]¶
Return pyproject.toml
textwith the on-demand settings applied.Sets
[tool.wads.ci.trigger].mode = "on-demand"(andrun_ci_markerwhen given),[tool.wads.ci.testing].python_versionsandtest_on_windows. Comments and layout survive (tomlkit), and a key already holding its target value is not touched, so applying this twice returns the first result unchanged.- Return type:
- wads.ci_trigger.stub_customizations(text)[source]¶
Split what a stub holds beyond the template into
(with_inputs, dropped).with_inputs(a flatwith:mapping, e.g.project-name) survives a re-render;droppednames everything else a re-render would silently lose. Raisesyaml.YAMLErrorwhen the text does not parse.>>> stub_customizations( ... "on: [push, pull_request]\njobs:\n ci:\n uses: x\n" ... " with:\n project-name: my_pkg\n" ... ) ({'project-name': 'my_pkg'}, []) >>> stub_customizations("on: [push]\njobs:\n ci:\n uses: x\n lint:\n runs-on: y\n")[1] ['`on: ["push"]`', 'job `lint`']
- wads.ci_trigger.stub_if_expression(run_ci_marker='[run ci]')[source]¶
The on-demand stub’s job-level pre-filter, as a workflow expression.
- Return type:
>>> stub_if_expression("[run ci]") "github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[run ci]')"
A quote in the marker is doubled, which is how workflow expressions escape it:
>>> stub_if_expression("it's") "github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'it''s')"
- wads.ci_trigger.stub_inputs_of(ci_path)[source]¶
The
with:inputs an existing stub file passes;{}if none or not a stub.- Return type:
- wads.ci_trigger.stub_shape(ci_text)[source]¶
The
pin(which a re-render keeps) and secretstransportof a stub.Defaults (
@master,named) for anything that is not a stub. A re-render writes the named transport whatever this reports (i2mint/wads#74, #101); seewads.migration.migrate_ci_to_stub().- Return type:
>>> stub_shape("uses: i2mint/wads/.github/workflows/uv-ci.yml@0.2.30") {'pin': '@0.2.30', 'transport': 'named'} >>> stub_shape(None) {'pin': '@master', 'transport': 'named'}
- wads.ci_trigger.trigger_for_workflow(ci_path)[source]¶
The
(mode, run_ci_marker)declared by the repo holdingci_path.Defaults when there is no such file or no pyproject. An invalid trigger table raises: a stub must never silently render
autofor a repo that asked foron-demand.
- wads.ci_trigger.unasked_workflows(repo, *, workflow='.github/workflows/ci.yml')[source]¶
Other workflow files in
repothat run without anyone asking (push, PR, cron).The flip only governs
workflow; these keep running and are worth knowing about.
- wads.ci_trigger.uncommitted(repo, paths)[source]¶
Which of
pathshave uncommitted changes (git status --porcelainlines).
- wads.ci_trigger.validate_trigger(mode, run_ci_marker)[source]¶
Validate a (mode, marker) pair with the same rules the CI setup job applies.
>>> validate_trigger("on-demand", "[run ci]") ('on-demand', '[run ci]') >>> validate_trigger("sometimes", "[run ci]") Traceback (most recent call last): ... ValueError: [tool.wads.ci.trigger].mode must be one of ('auto', 'on-demand'), got 'sometimes'