Repository navigation
759 lines (712 loc) · 40.3 KB
/
Copy pathvalidate-task.yml
File metadata and controls
759 lines (712 loc) · 40.3 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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
name: Validate task
# The fleet validation gate, hosted here once and reached by every repo's test-pull-request stub and its own publish-release stub.
# The jobs are test-matrix, lint, unit-test, and validate.
# The test-matrix job checks the python-versions and test-matrix inputs and builds the unit-test matrix.
# The lint job is the fleet doc-lint block plus language lint, by tree detection.
# For Python it lints the declared directories, and it also runs the prose gate and the repo gate.
# The unit-test job is a generic dotnet test, and a pytest or unittest suite under coverage in each Python directory.
# It runs once per interpreter the python-versions input names, or once per test-matrix leg.
# The validate job is the validate hook, a repo's own domain checks.
# Examples are an ESPHome compile, a Hugo build, a KiCad ERC, a codegen-drift check, or PowerShell tests.
# No permissions beyond contents: read where a job needs one, since every job here only checks out and reads.
# No required inputs, markdown-exclude-globs, repo-gate-exclude-globs, python-directories, python-versions and test-matrix are the optional inputs, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort.
# Hub-owned gates and default hooks resolve through $/ at the reusable workflow's commit, so each implementation is reproducible against the caller's released pin without a second checkout.
on:
workflow_call:
inputs:
# Appended after '**/*.md', not validated: a negated glob excludes, a non-negated one adds to what is linted.
markdown-exclude-globs:
description: Negated globs to exclude, one per line (for example '!content/**'), appended after '**/*.md'. A non-negated line adds to what is linted rather than narrowing it.
required: false
type: string
default: ''
# Threaded to the repo-gate composite action's own exclude-globs input, unvalidated.
repo-gate-exclude-globs:
description: Git pathspec patterns to exclude, one per line (for example 'themes/PaperMod/**'), narrowing the sha-pin, eol-coverage, and composite-actions scans via repo_gate.py's --exclude.
required: false
type: string
default: ''
# Resolved and checked by the python-directories action, which both Python jobs run, so a malformed declaration fails before any tool runs.
python-directories:
description: Repository-relative directories holding a Python project, one per line (for example 'RegressionTests'), each with a tracked pyproject.toml. Each is linted, type checked, and tested under coverage. Empty gates the root where a root pyproject.toml is tracked. List '.' alongside a subdirectory to keep the root gated too.
required: false
type: string
default: ''
# Built into the unit-test matrix by the test-matrix job, and read raw by its python-versions check and the lint job's Sync Python dependencies step.
python-versions:
description: JSON array of the Python versions the pytest leg runs under, one leg per entry, for example ["3.13", "3.14"]. With test-matrix set, only the first entry is used, as the fallback interpreter. Quote each entry, since an unquoted one is a JSON number rather than a version string.
required: false
type: string
default: '["3.13"]'
test-matrix:
description: JSON array of objects, one pytest leg per entry. Each has a unique label of 1 to 38 characters from A-Z, a-z, 0-9, underscore, dot, or hyphen. Each may have a python-version, defaulting to the first python-versions entry. Each leg calls the caller's .github/actions/install-test-deps hook after the base install, with the entry as its leg input. Empty runs the python-versions legs with no hook.
required: false
type: string
default: ''
secrets:
CODECOV_TOKEN:
required: false
jobs:
test-matrix:
name: Build test matrix job
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
matrix: ${{ steps.build.outputs.matrix }}
steps:
- name: Validate test-matrix input step
if: ${{ inputs.test-matrix != '' }}
env:
TEST_MATRIX: ${{ inputs.test-matrix }}
run: |
set -Eeuo pipefail
if ! jq -e -s '
length == 1 and (.[0] |
type == "array" and length > 0
and all(.[]; type == "object"
and (.label | type == "string" and test("\\A[A-Za-z0-9_.-]{1,38}\\z"))
and ((has("python-version") | not) or (.["python-version"] | type == "string" and test("\\S"))))
and ((map(.label) | unique | length) == length))
' <<<"$TEST_MATRIX" >/dev/null 2>&1; then
escaped=${TEST_MATRIX//'%'/%25}
escaped=${escaped//$'\r'/%0D}
escaped=${escaped//$'\n'/%0A}
echo "::error::The test-matrix input must be a non-empty JSON array of objects, each with a unique label of 1 to 38 characters from A-Z, a-z, 0-9, underscore, dot, or hyphen, and a python-version, when present, that is a string with a non-whitespace character, got: $escaped"
exit 1
fi
# An entry holding no non-whitespace character would otherwise run green on an interpreter nobody chose.
# That is because setup-uv trims what it reads and then reads an empty input as an absent one.
# A caller reaches it by interpolating an unset value of its own, and a stray space in that template reaches it just as well.
# That is why the entry test is for content rather than for length.
# An unquoted entry is the other half of the same slip, and what it costs depends on the number fromJSON parsed it into.
# Some of those numbers uv resolves to a real interpreter rather than refusing.
# So that shape is a second silent pass, which this check prevents rather than names as a cause.
# The check reads the raw input rather than matrix.python-version, which by then cannot say whether its 3.2 was written 3.2 or 3.20.
# With test-matrix set, python-versions never expands, so this step alone guards the first entry that setup-uv reads.
- name: Validate python-versions input step
env:
PYTHON_VERSIONS: ${{ inputs.python-versions }}
run: |
set -Eeuo pipefail
if ! jq -e -s 'length == 1 and (.[0] | type == "array" and length > 0 and all(.[]; type == "string" and test("\\S")))' <<<"$PYTHON_VERSIONS" >/dev/null 2>&1; then
# The annotation carries the rejected value, so it is escaped per the workflow command encoding rules first.
# A raw newline would end the annotation and let whatever follows it start a command of its own.
# The runner reads a command off stderr as readily as stdout, so redirecting rather than escaping would close nothing.
# The percent substitution runs first, since running it after the others would re-encode the percent each of them just introduced.
escaped=${PYTHON_VERSIONS//'%'/%25}
escaped=${escaped//$'\r'/%0D}
escaped=${escaped//$'\n'/%0A}
echo "::error::The python-versions input must be a non-empty JSON array of non-empty quoted version strings, for example '[\"3.13\", \"3.14\"]', got: $escaped"
exit 1
fi
# A matrix built inline from inputs expands at planning time, before needs: runs, so a malformed value could not be named.
- name: Build unit-test matrix step
id: build
env:
TEST_MATRIX: ${{ inputs.test-matrix }}
PYTHON_VERSIONS: ${{ inputs.python-versions }}
run: |
set -Eeuo pipefail
if [[ -n "$TEST_MATRIX" ]]; then
matrix=$(jq -c -s '{include: .[0]}' <<<"$TEST_MATRIX")
else
matrix=$(jq -c --argjson versions "$PYTHON_VERSIONS" -n '{"python-version": $versions}')
fi
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
lint:
name: Lint sources job
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# Full history, since the dead-path prose rule keys on deletion history and the prose gate diffs against a base branch.
# A shallow clone holds neither, and both stand down there rather than pass blind.
- name: Checkout code step
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# The fleet doc-lint block, hosted once rather than carried by every repo of every type.
# An empty markdown-exclude-globs leaves a blank line here, which the action ignores, so the default caller passes nothing extra.
- name: Lint Markdown step
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
with:
globs: |
**/*.md
${{ inputs.markdown-exclude-globs }}
# The spell check covers README + HISTORY only, per CODESTYLE.md "Markdown and Spelling".
- name: Spell check step
uses: streetsidesoftware/cspell-action@6f3c77c1406bc930f944ba97e9801a22e42caf58 # v9.1.0
with:
files: |
README.md
HISTORY.md
incremental_files_only: false
- name: Lint workflows step
uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
- name: Check EditorConfig step
run: docker run --rm --pull=always -v "$PWD":/check --workdir /check ghcr.io/editorconfig-checker/editorconfig-checker:latest
# The file list comes from git rather than a glob, and the docker run is skipped rather than invoked on an empty argument list.
# That is how a repo with no shell scripts stays clean instead of failing on a linter's own no-file usage error.
# A `*.sh` glob alone misses a bare-command script with no extension, so a shebang-matched file joins the list too.
- name: Check shell scripts step
run: |
set -Eeuo pipefail
# Tokenizes rather than substring-matches, so `#!/usr/bin/python bash` is not mistaken for a shell script.
# `docker_lint.py`'s shell_shebang_interpreter is the same logic; keep both in sync on a change here.
is_shell_shebang() {
local line="$1"
[[ "$line" == '#!'* ]] || return 1
local -a tokens
read -ra tokens <<< "${line#\#!}"
[ "${#tokens[@]}" -gt 0 ] || return 1
local interpreter="${tokens[0]##*/}"
if [[ "$interpreter" == "bash" || "$interpreter" == "sh" ]]; then
return 0
fi
[[ "$interpreter" == "env" ]] || return 1
local -a args=("${tokens[@]:1}")
local token
while [ "${#args[@]}" -gt 0 ]; do
token="${args[0]}"
if [[ "$token" == "--" ]]; then
args=("${args[@]:1}")
break
fi
if [[ "$token" == "-S" || "$token" == "--split-string" ]]; then
args=("${args[@]:1}")
break
fi
if [[ "$token" == -* ]]; then
case "$token" in
-u | --unset | -C | --chdir)
args=("${args[@]:2}")
;;
*)
args=("${args[@]:1}")
;;
esac
continue
fi
if [[ "$token" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
args=("${args[@]:1}")
continue
fi
break
done
[ "${#args[@]}" -gt 0 ] || return 1
local cmd="${args[0]##*/}"
[[ "$cmd" == "bash" || "$cmd" == "sh" ]]
}
mapfile -t scripts < <(git ls-files '*.sh')
mapfile -d '' -t candidates < <(git ls-files -z)
for file in "${candidates[@]}"; do
base="${file##*/}"
# Never follows a tracked symlink: -h short-circuits before -f's own dereferencing stat.
if [[ "$base" != *.* ]] && [ ! -h "$file" ] && [ -f "$file" ]; then
# `head` reads a no-trailing-newline file cleanly and still fails on a genuine read error.
first_line="$(head -n 1 -- "$file")"
if is_shell_shebang "$first_line"; then
scripts+=("$file")
fi
fi
done
if [ "${#scripts[@]}" -gt 0 ]; then
docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt mirror.gcr.io/koalaman/shellcheck:stable -- "${scripts[@]}"
docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt mirror.gcr.io/mvdan/shfmt:latest -d -- "${scripts[@]}"
else
echo "no shell scripts are tracked"
fi
# The peer of the step above: the file list comes from git, and the container only starts when there is something for it to check.
# A repo with no .ps1 files pays nothing for the pull.
# 1.23.0 rather than the newest, since 1.24.0 needs a newer System.Management.Automation than this image carries, which installs and then fails to import.
- name: Check PowerShell scripts step
run: |
set -Eeuo pipefail
mapfile -t ps_scripts < <(git ls-files '*.ps1')
if [ "${#ps_scripts[@]}" -gt 0 ]; then
docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \
pwsh -NoProfile -Command '
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers
Import-Module PSScriptAnalyzer
$files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ }
$found = @()
foreach ($file in $files) { $found += Invoke-ScriptAnalyzer -Path $file -Settings ./PSScriptAnalyzerSettings.psd1 }
Write-Host "Checked $($files.Count) file(s)"
if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 }
Write-Host "no findings"
'
else
echo "no PowerShell scripts are tracked"
fi
# Language lint by tree detection: a caller carries a language marker file or it does not.
# Python is the one exception, gated in the directories the python-directories input declares.
- name: Setup .NET SDK step
if: hashFiles('**/*.csproj') != ''
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.x
- name: Restore dotnet tools step
if: >-
hashFiles('**/*.csproj') != '' && hashFiles('.config/dotnet-tools.json') != ''
run: dotnet tool restore
# CSharpier is a local dotnet tool, so it needs the manifest restored above.
# A caller with no manifest skips rather than failing on a generic unknown-command error against a tool the runner never installed.
- name: Check C# formatting step
if: >-
hashFiles('**/*.csproj') != '' && hashFiles('.config/dotnet-tools.json') != ''
run: dotnet csharpier check .
# The dotnet SDK ships dotnet format, so it needs no tool restore.
# The severity and verbosity flags match the fleet's canonical invocation, per CODESTYLE.md and PhotoCleaner's own gate.
- name: Check C# style step
if: hashFiles('**/*.csproj') != ''
run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed
- name: Resolve Python directories step
id: python
uses: $/.github/actions/python-directories
with:
directories: ${{ inputs.python-directories }}
report-uncovered: 'true'
# @latest rather than a version pin, since a `uvx <tool>@<ver>` pin is nothing Dependabot tracks and would silently go stale, where the action SHA above is Dependabot-tracked.
# This is the one place the fleet's uvx tools are pinned or floated, decided here.
- name: Setup uv step
if: steps.python.outputs.any == 'true' || hashFiles('.github/actions/**/action.yml', '.github/actions/**/action.yaml') != ''
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: "3.13"
# A declared pip directory's requirements are installed for its type check, which an undeclared root never had.
# They install under the first interpreter the caller names, since they need not resolve under the one this job pins, and UV_PYTHON is what every uv call below reads for it.
- name: Sync Python dependencies step
if: steps.python.outputs.any == 'true'
env:
PYTHON_VERSIONS: ${{ inputs.python-versions }}
PYTHON_PROJECTS: ${{ steps.python.outputs.projects }}
DECLARED: ${{ steps.python.outputs.declared }}
run: |
set -Eeuo pipefail
# One resolve over the collected requirements files, since the glob sorts the base file last and a per-file install would let its pins downgrade what the test file just resolved.
install_requirements() {
if [ -n "${PIP_PYTHON:-}" ]; then
export UV_PYTHON="$PIP_PYTHON"
fi
uv venv
local requirement_args=()
local file
for file in requirements*.txt; do
requirement_args+=(-r "$file")
done
uv pip install "${requirement_args[@]}"
if python3 -c 'import sys, tomllib; sys.exit("project" not in tomllib.load(open("pyproject.toml", "rb")))'; then
uv pip install -e .
fi
}
while IFS=$'\t' read -r kind dir; do
[ -n "$dir" ] || continue
if [ "$kind" = uv ]; then
(cd "$dir" && uv sync --all-groups --frozen) < /dev/null
elif [ "$kind" = pip ] && [ "$DECLARED" = "true" ]; then
# Read here rather than through fromJSON, which would fail this job on a malformed input with no cause named.
if ! PIP_PYTHON=$(jq -er -s 'if length == 1 and (.[0] | type == "array" and length > 0 and (.[0] | type == "string" and test("\\S"))) then .[0][0] else error("") end' <<< "$PYTHON_VERSIONS" 2> /dev/null); then
echo "::error::The python-versions input must be a non-empty JSON array of quoted version strings, whose first entry installs the declared pip directory $dir."
exit 1
fi
(cd "$dir" && install_requirements) < /dev/null
fi
done <<< "$PYTHON_PROJECTS"
- name: Lint Python step
if: steps.python.outputs.any == 'true'
env:
PYTHON_PROJECTS: ${{ steps.python.outputs.projects }}
run: |
set -Eeuo pipefail
while IFS=$'\t' read -r kind dir; do
[ -n "$dir" ] || continue
echo "ruff check in $dir"
if [ "$kind" = uv ]; then
(cd "$dir" && uv run ruff check .) < /dev/null
else
(cd "$dir" && uvx ruff@latest check .) < /dev/null
fi
done <<< "$PYTHON_PROJECTS"
- name: Check Python formatting step
if: steps.python.outputs.any == 'true'
env:
PYTHON_PROJECTS: ${{ steps.python.outputs.projects }}
run: |
set -Eeuo pipefail
while IFS=$'\t' read -r kind dir; do
[ -n "$dir" ] || continue
echo "ruff format --check in $dir"
if [ "$kind" = uv ]; then
(cd "$dir" && uv run ruff format --check .) < /dev/null
else
(cd "$dir" && uvx ruff@latest format --check .) < /dev/null
fi
done <<< "$PYTHON_PROJECTS"
# Mypy takes precedence where a config declares both, matching every repo that runs mypy in CI today.
# A pyright-only config, with no mypy section, falls back to pyright instead.
# A declared directory's standalone mypy.ini or .mypy.ini, setup.cfg [mypy] section, or pyrightconfig.json counts too, the placement a Home Assistant integration keeps, while the undeclared root reads only the pyproject sections it always did.
# A declared subdirectory configuring neither falls back to the repository root's config and runs the checker from the root, the way a monorepo keeping one config there runs it.
# A repo that genuinely wants both enforced adds the second as its own validate hook step.
- name: Type check Python step
if: steps.python.outputs.any == 'true'
env:
PYTHON_PROJECTS: ${{ steps.python.outputs.projects }}
DECLARED: ${{ steps.python.outputs.declared }}
run: |
set -Eeuo pipefail
has_section() {
[ -f "$1" ] && grep -Eq "^[[:space:]]*\[$2\]" "$1"
}
find_checker() {
checker=
if has_section "$1/pyproject.toml" 'tool\.mypy' || { [ "$2" = true ] && { [ -f "$1/mypy.ini" ] || [ -f "$1/.mypy.ini" ] || has_section "$1/setup.cfg" mypy; }; }; then
checker=mypy
elif has_section "$1/pyproject.toml" 'tool\.pyright' || { [ "$2" = true ] && [ -f "$1/pyrightconfig.json" ]; }; then
checker=pyright
fi
}
# Reads python_version only from the one config file mypy loads, and only from its mypy section.
pins_python_version() {
local file section
if [ -f "$1/mypy.ini" ]; then
file="$1/mypy.ini" section=mypy
elif [ -f "$1/.mypy.ini" ]; then
file="$1/.mypy.ini" section=mypy
elif has_section "$1/pyproject.toml" 'tool\.mypy'; then
file="$1/pyproject.toml" section=tool.mypy
elif has_section "$1/setup.cfg" mypy; then
file="$1/setup.cfg" section=mypy
else
return 1
fi
awk -v want="[$section]" '/^[[:space:]]*\[/ { h = $0; sub(/[#;].*/, "", h); gsub(/[[:space:]]/, "", h); on = (h == want); next } on && /^[[:space:]]*python_version[[:space:]]*[=:]/ { found = 1 } END { exit !found }' "$file"
}
while IFS=$'\t' read -r kind dir; do
[ -n "$dir" ] || continue
find_checker "$dir" "$DECLARED"
config_dir="$dir"
if [ -z "$checker" ] && [ "$DECLARED" = "true" ] && [ "$dir" != . ]; then
find_checker . true
config_dir=.
fi
if [ -z "$checker" ] && [ "$DECLARED" = "true" ]; then
echo "::error::The declared Python directory $dir configures neither mypy nor pyright, in its pyproject.toml, in a mypy.ini, .mypy.ini, setup.cfg, or pyrightconfig.json beside it, or in the same files at the repository root. Every Python directory owes a type check."
exit 1
elif [ -z "$checker" ]; then
echo "no [tool.mypy] or [tool.pyright] section in $dir/pyproject.toml, skipping"
continue
fi
echo "$checker in $dir, configured in $config_dir"
project="$(cd "$dir" && pwd)"
# Run from the root, a checker reads the root config's own file selection, so the directory is named to keep it the one checked.
targets=()
if [ "$config_dir" != "$dir" ]; then
targets=("$dir")
fi
venv_python="$project/.venv/bin/python"
if [ "$kind" = uv ]; then
(cd "$config_dir" && uv run --project "$project" "$checker" "${targets[@]}") < /dev/null
elif [ "$kind" = pip ] && [ "$DECLARED" = "true" ] && [ "$checker" = mypy ]; then
if "$venv_python" -c 'import importlib.util, sys; sys.exit(importlib.util.find_spec("mypy") is None)'; then
# The venv's own mypy imports the plugins installed beside it and targets its interpreter already.
(cd "$config_dir" && "$venv_python" -m mypy "${targets[@]}") < /dev/null
else
# Left alone, mypy under uvx targets the interpreter it runs under, so the venv's version is passed unless the config pins its own, which the flag would override.
version_args=()
if ! pins_python_version "$config_dir"; then
version_args=(--python-version "$("$venv_python" -c 'import sys; print("%d.%d" % sys.version_info[:2])')")
fi
(cd "$config_dir" && uvx mypy@latest --python-executable "$venv_python" "${version_args[@]}" "${targets[@]}") < /dev/null
fi
elif [ "$kind" = pip ] && [ "$DECLARED" = "true" ]; then
(cd "$config_dir" && uvx pyright@latest --pythonpath "$venv_python" "${targets[@]}") < /dev/null
else
(cd "$config_dir" && uvx "$checker@latest" "${targets[@]}") < /dev/null
fi
done <<< "$PYTHON_PROJECTS"
- name: Read comments label step
id: comments-label
if: github.event.pull_request != null
env:
GH_TOKEN: ${{ github.token }}
LABELS_PATH: repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels
PAYLOAD_LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'comments') }}
run: |
set -Eeuo pipefail
if names=$(gh api --paginate --jq '.[].name' "$LABELS_PATH"); then
labeled=false
if grep -qxiF comments <<< "$names"; then
labeled=true
fi
echo "Read the pull request's labels live, comments label present: $labeled."
else
labeled=$PAYLOAD_LABELED
echo "The pull request's labels could not be read live, so the event payload's snapshot decides, comments label present: $labeled."
fi
echo "labeled=$labeled" >> "$GITHUB_OUTPUT"
- name: Resolve prose base step
id: prose-base
if: github.event.pull_request != null
env:
EVENT_BASE: ${{ github.event.pull_request.base.sha }}
run: |
set -Eeuo pipefail
base=$EVENT_BASE
if git rev-parse --verify --quiet 'HEAD^2^{commit}' > /dev/null; then
base=$(git rev-parse --verify 'HEAD^1^{commit}')
fi
echo "Prose diff base: $base, the event's base.sha: $EVENT_BASE."
echo "base=$base" >> "$GITHUB_OUTPUT"
# A publish run's content is already gated by the pull request that introduced it.
- name: Check prose step
if: github.event.pull_request != null
uses: $/.github/actions/prose-gate
with:
base: ${{ steps.prose-base.outputs.base }}
# The label is read off the pull request rather than passed as an input a caller sets.
# A repository cannot then leave the override on, since it is reapplied per change by hand.
# A promotion runs develop into the default branch, and it diffs against that branch's tip.
# Its scope is therefore every line promoted since the last one, all of it already reviewed where it landed.
# So the comment rule stands down there, rather than reporting a whole release's comments as this change's.
# The head branch is tested as well as the base, since a feature branch may target the default branch directly and that one carries new lines.
# The head repository is tested too, since a fork's branch may be named `develop` and its lines were reviewed nowhere.
allow-comments: ${{ steps.comments-label.outputs.labeled == 'true' || (github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'develop' && github.base_ref == github.event.repository.default_branch) }}
# An empty repo-gate-exclude-globs leaves the action's own input at its default, so the default caller excludes nothing.
- name: Check repo gates step
uses: $/.github/actions/repo-gate
with:
exclude-globs: ${{ inputs.repo-gate-exclude-globs }}
# No job-level if: here, since GitHub Actions does not evaluate hashFiles in a job condition, only a step one.
# Every step below carries its own guard instead, a tree-detection one on the .NET half and the resolved Python directories on the Python half.
# A caller with no *Tests*.csproj and no Python directory runs every guard false, and the job reports success having done nothing, which is the clean skip this job promises.
# A declared Python directory owes a tests/ suite under coverage, per D1.6, so one without tests/ fails here rather than skipping.
# The root default keeps the old gate, a pytest suite in tests/ beside a uv.lock or requirements*.txt, since a caller declaring nothing has not yet taken on that obligation.
# The matrix dimension is the interpreter, or the test-matrix leg when that is set.
# A caller naming neither runs the single leg this job ran before the input existed.
# A python-versions leg names an interpreter whether or not one ran.
# So a .NET-only caller reports a Python version its Python steps never used.
# A caller naming several also repeats this job's .NET half once per leg, which spends runner minutes and re-uploads an identical report rather than measuring anything new, and splitting the two halves into jobs of their own is what that costs, owed by the first repo to carry both a .NET test project and more than one interpreter.
unit-test:
name: Unit test job (${{ matrix.label || format('Python {0}', matrix.python-version) }})
needs: test-matrix
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
# A failing interpreter must not cancel its siblings, since which of them fail is the whole point of running more than one.
fail-fast: false
matrix: ${{ fromJSON(needs.test-matrix.outputs.matrix) }}
steps:
- name: Checkout code step
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check caller install-test-deps hook step
if: ${{ inputs.test-matrix != '' }}
env:
HOOK_FOUND: ${{ hashFiles('.github/actions/install-test-deps/action.yml') != '' }}
run: |
set -Eeuo pipefail
if [[ "$HOOK_FOUND" != "true" ]]; then
echo "::error::The test-matrix input is set but .github/actions/install-test-deps/action.yml is missing."
exit 1
fi
- name: Setup .NET SDK step
if: hashFiles('**/*Tests*.csproj') != ''
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.x
# --coverage-output stays unset because pinning one filename gives every test project in the solution the same path, and the last to finish overwrites the rest.
# The default <guid>.cobertura.xml written instead is a name codecov-cli's finder does not match, so each report is prefixed rather than renamed, keeping the guid that makes it unique.
- name: Run unit tests step
if: hashFiles('**/*Tests*.csproj') != ''
run: |
set -Eeuo pipefail
rm -rf ./coverage
dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage
written=0
for report in ./coverage/*.cobertura.xml; do
[ -e "$report" ] || continue
renamed="./coverage/coverage-$(basename "$report")"
mv "$report" "$renamed"
if [ -s "$renamed" ]; then
written=1
fi
done
if [ "$written" -eq 0 ]; then
echo "::error::This run wrote no non-empty Cobertura report under ./coverage. Reference Microsoft.Testing.Extensions.CodeCoverage from each test project, per CODESTYLE.md's .NET side."
exit 1
fi
# Report-only: fail_ci_if_error is false so a Codecov hiccup or an absent token never fails the gate.
- name: Upload coverage to Codecov step
if: hashFiles('**/*Tests*.csproj') != ''
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
directory: ./coverage
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Resolve Python directories step
id: python
uses: $/.github/actions/python-directories
with:
directories: ${{ inputs.python-directories }}
# A root the undeclared default skips still sets uv up, so an empty cache is a normal outcome rather than an error at post-job save.
- name: Setup uv step
if: steps.python.outputs.any == 'true'
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
python-version: ${{ matrix.python-version || fromJSON(inputs.python-versions)[0] }}
ignore-nothing-to-cache: true
- name: Install Python dependencies step
if: ${{ steps.python.outputs.any == 'true' && inputs.test-matrix != '' }}
env:
PYTHON_PROJECTS: ${{ steps.python.outputs.projects }}
DECLARED: ${{ steps.python.outputs.declared }}
run: |
set -Eeuo pipefail
install_requirements() {
uv venv
local requirement_args=()
local file
for file in requirements*.txt; do
requirement_args+=(-r "$file")
done
uv pip install "${requirement_args[@]}"
if python3 -c 'import sys, tomllib; sys.exit("project" not in tomllib.load(open("pyproject.toml", "rb")))'; then
uv pip install -e .
fi
}
while IFS=$'\t' read -r kind dir; do
[ -n "$dir" ] || continue
if [ "$DECLARED" != "true" ] && [ ! -d "$dir/tests" ]; then
continue
fi
(
cd "$dir"
case "$kind" in
uv) uv sync --all-groups --frozen ;;
pip) install_requirements ;;
esac
) < /dev/null
done <<< "$PYTHON_PROJECTS"
- name: Run caller install-test-deps hook step
if: ${{ steps.python.outputs.any == 'true' && inputs.test-matrix != '' }}
uses: ./.github/actions/install-test-deps
with:
leg: ${{ toJSON(matrix) }}
directories: ${{ steps.python.outputs.projects }}
# A directory's dependency manifest decides how its suite runs, a committed uv.lock or a requirements*.txt through pytest, and neither through unittest, the lint-only profile being stdlib-only.
# --cov-report=xml names the report format and selects nothing to measure, so a pytest directory's own pyproject.toml supplies the --cov selector, per D1.6.
# The report is checked rather than assumed, because the best-effort upload below reads a missing file exactly as it reads a healthy run.
# The pre-run delete makes that a check on what this run wrote, since a committed coverage.xml would otherwise satisfy it without any measurement.
# Each directory's run reads /dev/null, since the loop's own list is its stdin and a suite reading stdin would swallow the directories after it.
- name: Run Python tests step
id: python-tests
if: steps.python.outputs.any == 'true'
env:
PYTHON_PROJECTS: ${{ steps.python.outputs.projects }}
DECLARED: ${{ steps.python.outputs.declared }}
HOOK_LEG: ${{ inputs.test-matrix != '' }}
run: |
set -Eeuo pipefail
# One resolve over the collected requirements files, since the glob sorts the base file last and a per-file install would let its pins downgrade what the test file just resolved.
install_requirements() {
if [ -n "${PIP_PYTHON:-}" ]; then
export UV_PYTHON="$PIP_PYTHON"
fi
uv venv
local requirement_args=()
local file
for file in requirements*.txt; do
requirement_args+=(-r "$file")
done
uv pip install "${requirement_args[@]}"
if python3 -c 'import sys, tomllib; sys.exit("project" not in tomllib.load(open("pyproject.toml", "rb")))'; then
uv pip install -e .
fi
}
reports=()
while IFS=$'\t' read -r kind dir; do
[ -n "$dir" ] || continue
if [ "$DECLARED" != "true" ] && { [ ! -d "$dir/tests" ] || [ "$kind" = lint-only ] || [ "$kind" = unsupported ]; }; then
echo "the undeclared root runs only a pytest suite with a dependency manifest beside it, skipping"
continue
fi
if [ "$kind" = unsupported ]; then
echo "::error::The declared Python directory $dir declares a project with no uv.lock or requirements*.txt of its own, so the validator cannot install its dependencies. Declare a uv workspace's root rather than its member."
exit 1
fi
if [ "$DECLARED" = "true" ] && [ ! -d "$dir/tests" ]; then
echo "::error::The declared Python directory $dir has no tests/ directory. Every Python directory owes a test suite run under coverage, per D1.6."
exit 1
fi
if [ "$DECLARED" = "true" ] && [ -z "$(find "$dir/tests" -name '*.py' -print -quit)" ]; then
echo "::error::The declared Python directory $dir has no Python files under tests/. Every Python directory owes a test suite run under coverage, per D1.6."
exit 1
fi
echo "tests in $dir ($kind)"
rm -f "$dir/coverage.xml"
(
cd "$dir"
case "$kind" in
uv)
if [ "${HOOK_LEG:-false}" = "true" ]; then
uv run --no-sync pytest --cov-report=xml
else
uv sync --all-groups --frozen
uv run pytest --cov-report=xml
fi
;;
pip)
if [ "${HOOK_LEG:-false}" != "true" ]; then
install_requirements
fi
.venv/bin/python -m pytest --cov-report=xml
;;
lint-only)
status=0
uvx coverage@latest run -m unittest discover -s tests || status=$?
if [ "$status" -eq 5 ]; then
echo "::error::unittest found no tests in $dir/tests. Discovery collects only unittest.TestCase subclasses, so a pytest-style plain test function does not count, it reads only files named test*.py, and it skips a nested test directory that has no __init__.py."
fi
[ "$status" -eq 0 ] || exit "$status"
uvx coverage@latest xml -o coverage.xml
;;
esac
) < /dev/null
if [[ ! -s "$dir/coverage.xml" ]]; then
echo "::error::This run wrote no coverage.xml in $dir. A pytest directory selects its coverage source in its own pyproject.toml, an addopts entry of --cov=<package> in practice, since --cov-report=xml alone measures nothing."
exit 1
fi
reports+=("$dir/coverage.xml")
done <<< "$PYTHON_PROJECTS"
files=$(IFS=,; printf '%s' "${reports[*]}")
echo "files=$files" >> "$GITHUB_OUTPUT"
# Best-effort: continue-on-error plus fail_ci_if_error false, so a missing token never reds the gate.
# The flag names the leg, so a multi-interpreter repo reads its legs apart in Codecov rather than seeing one merged number for all of them.
- name: Upload coverage to Codecov step (Python)
if: steps.python-tests.outputs.files != ''
continue-on-error: true
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ steps.python-tests.outputs.files }}
flags: python-${{ matrix.label || matrix.python-version }}
fail_ci_if_error: false
validate:
name: Validate repository job
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code step
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# A local composite action resolves at step time from the workspace, which is what makes this fallback expressible at all.
# It runs the caller's own hook when it carries one, else the hub's no-op default.
- name: Run repository validate hook step
if: hashFiles('.github/actions/validate/action.yml') != ''
uses: ./.github/actions/validate
- name: Run default validate hook step
if: hashFiles('.github/actions/validate/action.yml') == ''
uses: $/.github/actions/validate-default