-
Notifications
You must be signed in to change notification settings - Fork 1
405 lines (360 loc) · 19.2 KB
/
Copy pathci.yml
File metadata and controls
405 lines (360 loc) · 19.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
name: Continuous Integration (uv)
# Inline uv CI (wads `github_ci_uv.yml`), not the reusable-workflow stub: the stub's
# github-pages job can't pass `epythet-spec`, and this repo pilots epythet v2 (see the
# Pages step). Once the pin is no longer needed, `wads-migrate ci-to-stub` applies.
# PyPI auth: publish uses secrets.PYPI_PASSWORD as an API token (uv publish).
on: [push, pull_request]
# Workflow-level env vars from [tool.wads.ci.env] in pyproject.toml.
# Populated by wads-migrate / wads init via template substitution.
# Contains ONLY non-secret values: PROJECT_NAME and literal defaults from
# env.defaults. Secret-backed vars (required/test/extra_envvars) are
# deliberately scoped to the jobs that run tests — see the validation and
# windows-validation jobs below. A workflow-level secret would be in scope
# for the setup job too, and GitHub then refuses to emit any job OUTPUT
# containing its value: a short value (e.g. a test level of "3") silently
# blanks python-versions, the matrix expands to nothing, and the run fails
# with no failing step (i2mint/wads#61).
env:
PROJECT_NAME: i2
jobs:
# First job: Read configuration from pyproject.toml
setup:
name: Read Configuration
runs-on: ubuntu-latest
outputs:
project-name: ${{ steps.config.outputs.project-name }}
python-versions: ${{ steps.config.outputs.python-versions }}
pytest-args: ${{ steps.config.outputs.pytest-args }}
coverage-enabled: ${{ steps.config.outputs.coverage-enabled }}
exclude-paths: ${{ steps.config.outputs.exclude-paths }}
test-on-windows: ${{ steps.config.outputs.test-on-windows }}
windows-blocking: ${{ steps.config.outputs.windows-blocking }}
tests-enabled: ${{ steps.config.outputs.tests-enabled }}
build-sdist: ${{ steps.config.outputs.build-sdist }}
build-wheel: ${{ steps.config.outputs.build-wheel }}
metrics-enabled: ${{ steps.config.outputs.metrics-enabled }}
metrics-config-path: ${{ steps.config.outputs.metrics-config-path }}
metrics-storage-branch: ${{ steps.config.outputs.metrics-storage-branch }}
metrics-python-version: ${{ steps.config.outputs.metrics-python-version }}
metrics-force-run: ${{ steps.config.outputs.metrics-force-run }}
ruff-enabled: ${{ steps.config.outputs.ruff-enabled }}
black-enabled: ${{ steps.config.outputs.black-enabled }}
mypy-enabled: ${{ steps.config.outputs.mypy-enabled }}
docs-enabled: ${{ steps.config.outputs.docs-enabled }}
licence-enabled: ${{ steps.config.outputs.licence-enabled }}
publish-enabled: ${{ steps.config.outputs.publish-enabled }}
skip-ci-marker: ${{ steps.config.outputs.skip-ci-marker }}
publish-marker: ${{ steps.config.outputs.publish-marker }}
trigger-mode: ${{ steps.config.outputs.trigger-mode }}
run-ci-marker: ${{ steps.config.outputs.run-ci-marker }}
commit-subject: ${{ steps.commit.outputs.subject }}
steps:
# ------------------------------------------------------------------
# The marker gates below (skip-ci, publish) match against the commit
# SUBJECT — its first line — and never against the whole message.
#
# Why: a squash-merge folds the ENTIRE PR BODY into the squash commit
# message. A `contains()` over the full message therefore fires on a
# PR that merely WRITES ABOUT a marker. That is not hypothetical: a PR
# body quoting the publish marker forced a publish on a repo that had
# publishing disabled, and turned a default branch that had been green
# for the first time in a year red.
#
# Why not `startsWith()`: this house's marker convention is TRAILING
# ("cw v1: an MIT replacement for argh ... [bump minor]") and GitHub
# appends " (#N)" to every squash subject. A prefix match would turn a
# gate that fires too often into one that SILENTLY NEVER FIRES — the
# same defect, in the direction nobody notices.
#
# Shell safety: the commit message is untrusted, attacker-influenced,
# multi-line text. It reaches bash ONLY through the environment, never
# spliced into a command line, so quotes, newlines and backticks in it
# cannot become shell syntax. The value is written with the
# $GITHUB_OUTPUT heredoc form under a per-run random delimiter.
#
# On events with no head commit (e.g. pull_request) the expression
# renders empty, the subject is empty, and every `contains()` gate is
# false — the same verdict the full-message form gave.
# ------------------------------------------------------------------
- name: Extract commit subject
id: commit
env:
HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: |
subject="${HEAD_COMMIT_MESSAGE%%$'\n'*}"
subject="${subject%$'\r'}"
delimiter="wads-subject-${RANDOM}${RANDOM}${RANDOM}"
{
printf 'subject<<%s\n' "$delimiter"
printf '%s\n' "$subject"
printf '%s\n' "$delimiter"
} >> "$GITHUB_OUTPUT"
printf 'commit subject: %s\n' "$subject"
- uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
run: uv python install 3.11
- name: Read CI Config
id: config
uses: i2mint/wads/actions/read-ci-config@master
with:
pyproject-path: .
# TRIGGER GATE. Every job after `setup` carries the same clause:
# (trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch'
# || contains(commit-subject, run-ci-marker))
# [tool.wads.ci.trigger].mode = "auto" (the default) makes it always true, so
# auto repos behave exactly as before. "on-demand" means NOTHING RUNS UNLESS
# ASKED: the commit SUBJECT must carry run_ci_marker (default "[run ci]"), or
# the run must be a manual workflow_dispatch (always allowed). Publishing and
# Pages obey the same gate. `!= 'on-demand'` fails OPEN to auto when
# read-ci-config installed a wads too old to emit the output: a repo that
# never asked for on-demand must not lose its CI to a version skew.
#
# An on-demand caller stub also pre-filters at zero cost (no runner is
# scheduled for an ordinary push). That pre-filter can only test the whole
# message (expressions have no split), so THIS clause, over the extracted
# subject, is the decision: a marker quoted only in a squash-merged PR body
# costs the setup job and runs nothing else.
# Second job: Validation using the config
validation:
name: Validation
if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))"
needs: setup
runs-on: ubuntu-latest
# Secret-backed env vars from [tool.wads.ci.env] land here (test jobs
# only, never workflow level — see the note on the top-level env block),
# rendered as `KEY: ${{ secrets.NAME || '' }}` (an unset secret renders
# as an empty string). The block is omitted entirely when none are
# declared. Names also present in env.defaults are not re-emitted here:
# the committed default at workflow level stays authoritative (matching
# the reusable workflow's export-ci-env). The publish and github-pages
# jobs deliberately do not receive these vars either — parity with
# uv-ci.yml, where only the test jobs export them.
strategy:
matrix:
python-version: ${{ fromJson(needs.setup.outputs.python-versions) }}
steps:
- uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
uses: i2mint/wads/actions/setup-python-uv@master
with:
python-version: ${{ matrix.python-version }}
- name: Install System Dependencies
uses: i2mint/wads/actions/install-system-deps@master
with:
pyproject-path: .
- name: Install Dependencies
uses: i2mint/wads/actions/install-deps-uv@master
- name: Format Source Code
if: needs.setup.outputs.ruff-enabled != 'false'
run: uvx ruff format .
- name: Format Source Code (black)
if: needs.setup.outputs.black-enabled == 'true'
run: uvx black .
- name: Lint Validation
if: needs.setup.outputs.ruff-enabled != 'false'
run: uvx ruff check --output-format=github ${{ needs.setup.outputs.project-name }}
# Licence perimeter: fail the build if the INSTALLED dependency closure
# carries a copyleft / non-commercial licence the project's policy
# forbids. Opt-in via [tool.wads.licence].enabled = true, so a repo that
# declares nothing sees no change in CI behaviour at all.
#
# Run once, on the FIRST python-versions entry only: the answer does not
# vary by interpreter, and N identical failures across the matrix is
# noise. (The Windows job is a separate job and never runs this.)
#
# `--python` is load-bearing. `uvx` runs the tool in its OWN isolated
# environment, which contains wads and nothing of the project; without
# pointing it at the project's .venv the check would read the wrong
# closure and report a confident, wrong green.
- name: Licence Perimeter
if: needs.setup.outputs.licence-enabled == 'true' && matrix.python-version == fromJson(needs.setup.outputs.python-versions)[0]
run: |
if [ ! -f ".venv/bin/activate" ]; then
echo "::error::no .venv found; the licence gate must read the project's own installed closure, not uvx's isolated one"
exit 1
fi
source .venv/bin/activate
uvx --from wads wads-licence-check . --python "$VIRTUAL_ENV/bin/python"
- name: Type Check (mypy)
if: needs.setup.outputs.mypy-enabled == 'true'
run: uvx mypy ${{ needs.setup.outputs.project-name }}
- name: Run Tests
if: needs.setup.outputs.tests-enabled != 'false'
uses: i2mint/wads/actions/run-tests-uv@master
with:
root-dir: ${{ needs.setup.outputs.project-name }}
pytest-args: ${{ needs.setup.outputs.pytest-args }}
exclude-paths: ${{ needs.setup.outputs.exclude-paths }}
coverage: ${{ needs.setup.outputs.coverage-enabled }}
- name: Track Code Metrics
if: needs.setup.outputs.metrics-enabled == 'true'
uses: i2mint/umpyre/actions/track-metrics@master
continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
config-path: ${{ needs.setup.outputs.metrics-config-path }}
storage-branch: ${{ needs.setup.outputs.metrics-storage-branch }}
python-version: ${{ needs.setup.outputs.metrics-python-version }}
force-run: ${{ needs.setup.outputs.metrics-force-run }}
# Optional Windows testing (if enabled in config)
windows-validation:
name: Windows Tests
if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && needs.setup.outputs.test-on-windows == 'true' && needs.setup.outputs.tests-enabled != 'false' && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))"
needs: setup
runs-on: windows-latest
# Fail-closed opt-in ([tool.wads.ci.testing].windows_blocking): only the
# literal 'true' makes the Windows leg block. An unset output - an older
# read-ci-config that does not emit it - yields '' != 'true', i.e. the
# historical informational behaviour. Blocking reddens the RUN; publish
# still does not depend on this job.
continue-on-error: ${{ needs.setup.outputs.windows-blocking != 'true' }}
env:
# PEP 540 UTF-8 mode: avoid cp1252 UnicodeDecode/EncodeError when test
# code reads source files or scripts print non-ASCII characters.
PYTHONUTF8: "1"
PYTHONIOENCODING: "utf-8"
# Secret-backed env vars from [tool.wads.ci.env] (see the note on the
# validation job). Empty when none are declared. Names colliding with
# the literals above are skipped — a duplicate key in one mapping
# would fail the whole workflow at parse time.
steps:
- uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
uses: i2mint/wads/actions/setup-python-uv@master
with:
python-version: ${{ fromJson(needs.setup.outputs.python-versions)[0] }}
- name: Install System Dependencies
uses: i2mint/wads/actions/install-system-deps@master
with:
pyproject-path: .
- name: Install Dependencies
uses: i2mint/wads/actions/install-deps-uv@master
- name: Run Tests
uses: i2mint/wads/actions/run-tests-uv@master
with:
root-dir: ${{ needs.setup.outputs.project-name }}
pytest-args: ${{ needs.setup.outputs.pytest-args }}
exclude-paths: ${{ needs.setup.outputs.exclude-paths }}
# Publishing job
#
# Gated by [tool.wads.ci.publish] in pyproject.toml (read via the setup job):
# - skip-ci-marker : when publishing is enabled, a commit SUBJECT LINE
# containing this substring skips the publish job
# (default "[skip ci]").
# - publish-enabled : whether publishing runs at all (default true).
# - publish-marker : when publishing is disabled, a commit SUBJECT LINE
# containing this substring forces the publish job
# (default "[publish]").
# - trigger-mode : in on-demand mode, publish also needs the run-ci
# marker in the subject or a workflow_dispatch —
# the TRIGGER GATE above `validation`.
# The publish-enabled check uses `== 'true'` (fail-closed): if an older wads
# without these outputs is installed by read-ci-config, publishing is skipped
# rather than run unintentionally.
#
# SUBJECT, not message: both markers are matched against the setup job's
# `commit-subject` output (the first line), never the full message, because
# a squash-merge folds the whole PR BODY into the squash commit message —
# see the note on the extraction step in the setup job.
publish:
name: Publish
permissions:
contents: write
if: "!contains(needs.setup.outputs.commit-subject, needs.setup.outputs.skip-ci-marker) && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (needs.setup.outputs.publish-enabled == 'true' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.publish-marker)) && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))"
needs: [setup, validation]
runs-on: ubuntu-latest
steps:
# `actions/checkout@v6` defaults to persist-credentials: true, configuring
# HTTPS auth in .git/config using `secrets.GITHUB_TOKEN`. Together with the
# job-level `permissions: contents: write`, this is exactly what the
# post-publish push-back needs — no per-repo SSH deploy key required. Do
# NOT re-add a "Force SSH for git remote" step: rewriting origin to an
# SSH URL overrides these credentials and reintroduces the push-back
# failure on every repo lacking an SSH_PRIVATE_KEY deploy key.
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
uses: i2mint/wads/actions/setup-python-uv@master
with:
python-version: ${{ fromJson(needs.setup.outputs.python-versions)[0] }}
create-venv: "false"
- name: Format Source Code
if: needs.setup.outputs.ruff-enabled != 'false'
run: uvx ruff format .
- name: Format Source Code (black)
if: needs.setup.outputs.black-enabled == 'true'
run: uvx black .
- name: Update Version Number
id: version
uses: i2mint/isee/actions/bump-version-number@master
- name: Build Distribution
uses: i2mint/wads/actions/build-dist-uv@master
with:
sdist: ${{ needs.setup.outputs.build-sdist }}
wheel: ${{ needs.setup.outputs.build-wheel }}
- name: Publish to PyPI
uses: i2mint/wads/actions/pypi-publish-uv@master
with:
pypi-token: ${{ secrets.PYPI_PASSWORD }}
# A second merge landing on the default branch mid-run used to make this
# push-back fail as non-fast-forward: PyPI had the release, but the bump
# commit and tag never landed and the run went red (i2mint/wads#81). The
# git-commit action replays the bump onto the moved branch and retries;
# see its `push-rebase-retries` input, and the note in
# i2mint/wads .github/workflows/uv-ci.yml on why no `concurrency` group.
- name: Commit Changes
uses: i2mint/wads/actions/git-commit@master
with:
commit-message: "**CI** Formatted code + Updated version to ${{ env.VERSION }} [skip ci]"
push: true
- name: Tag Repository
uses: i2mint/wads/actions/git-tag@master
with:
tag: ${{ env.VERSION }}
message: "Release version ${{ env.VERSION }}"
push: true
# Optional GitHub Pages (skipped when [tool.wads.ci.docs].enabled = false)
# Depends on validation (not publish) so docs still publish when the publish
# job is disabled via [tool.wads.ci.publish].enabled = false.
github-pages:
name: Publish GitHub Pages
permissions:
contents: write
pages: write
id-token: write
if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && needs.setup.outputs.docs-enabled != 'false' && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))"
needs: [setup, validation]
runs-on: ubuntu-latest
steps:
# Check out first so install-system-deps can read [tool.wads.ops.*] from
# pyproject.toml. The epythet action self-checks-out again internally;
# apt-installed system deps persist across that re-checkout.
- uses: actions/checkout@v6
# Install [tool.wads.ops.*] system deps (e.g. portaudio for pyaudio) so
# the epythet docs build, which pip-installs this package to extract
# docstrings, doesn't fail on a missing native library at import time.
# No-op when the package declares no system deps.
- name: Install System Dependencies
uses: i2mint/wads/actions/install-system-deps@master
with:
pyproject-path: .
- uses: i2mint/epythet/actions/publish-github-pages@master
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
ignore: "tests/,scrap/,examples/"
# WP5 pilot: opt into epythet v2 ahead of the fleet default (i2mint/epythet#16)
epythet-spec: "epythet>=0.2,<0.3"