forked from google/adk-samples
-
Notifications
You must be signed in to change notification settings - Fork 0
392 lines (354 loc) · 18 KB
/
Copy pathpython-validate-recipe.yml
File metadata and controls
392 lines (354 loc) · 18 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
name: Validate Python Recipe
# Python-specific validation for each affected Python recipe:
# `.env.example` env-key notice, AST-based env-var extraction check,
# no-local-Ruff-config, `[project]` metadata rules, and a
# hardcoded-model-name notice. Structural checks live in
# `validate-recipe-structure.yml`.
on:
pull_request:
paths:
- 'core/**'
- 'contrib/**'
- 'skills/**'
- '.github/workflows/python-validate-recipe.yml'
- '.github/scripts/check_env_vars.py'
- '.github/scripts/check_recipe_pyproject.py'
- 'tools/affected_recipes.py'
- 'tools/validate_manifest.py'
- 'pyproject.toml'
workflow_dispatch:
permissions:
contents: read
jobs:
validate-python-recipes:
name: Check Python-specific recipe constraints
runs-on: ubuntu-latest
# 20 minutes covers `uv sync` + a full-repo scan on the workflow_dispatch
# / infra-changed path, with headroom. Prevents the default 6-hour job
# ceiling from applying when a check hangs.
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # ratchet:actions/checkout@v4
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # ratchet:astral-sh/setup-uv@v9.0.0
with:
python-version: "3.11"
enable-cache: false
- name: Install validation dependencies
# affected_recipes.py imports validate_manifest and needs PyYAML;
# `uv sync` installs both (same install step the sibling workflow
# validate-recipe-structure.yml uses).
run: uv sync
- name: Detect affected Python recipe directories
id: affected
env:
# Pass GitHub-controlled values through env vars rather than
# interpolating them into the shell body (expression injection
# hardening — see GitHub Actions security hardening guide).
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
# workflow_dispatch has no PR base ref, so the diff-based
# detection can't run. Validate every Python recipe instead
# (mirrors the run_all path in python-tests.yml). Without this
# short-circuit, `git diff` below would fail loudly.
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "workflow_dispatch — validating all Python recipes."
echo "validate_all=true" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch origin "$BASE_REF"
CHANGED_FILES=$(git diff --name-only "origin/$BASE_REF"...HEAD)
echo "Changed files:"
echo "$CHANGED_FILES"
# If any infrastructure file that controls this workflow's
# behaviour changed (the workflow itself, a checker script, or
# a shared tool), validate ALL Python recipes so the new
# configuration is applied everywhere and not only to recipes
# touched by the same PR.
INFRA_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^(\.github/(workflows/python-validate-recipe\.yml|scripts/(check_env_vars|check_recipe_pyproject)\.py)|tools/(affected_recipes|validate_manifest)\.py|pyproject\.toml)$" || true)
if [ -n "$INFRA_CHANGED" ]; then
echo "Workflow or Python-specific tool changed — validating all Python recipes."
echo "validate_all=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Otherwise, translate the file diff into the set of Python
# recipes it touched via the shared helper (tested by
# tools/tests/test_affected_recipes.py — table-driven, so this
# workflow doesn't reimplement the layout matrix in bash).
# NOT `--no-project`: affected_recipes.py imports PyYAML and the
# `validate_manifest` package, both provided by the project venv
# created by `uv sync` above.
CHANGED_RECIPES=$(echo "$CHANGED_FILES" | uv run python tools/affected_recipes.py --language python)
echo "Python recipes to validate:"
echo "$CHANGED_RECIPES"
{
echo "changed_recipes<<RECIPES_EOF"
echo "$CHANGED_RECIPES"
echo "RECIPES_EOF"
} >> "$GITHUB_OUTPUT"
echo "validate_all=false" >> "$GITHUB_OUTPUT"
- name: Collect all Python recipe directories (workflow_dispatch or infra changed)
id: all_recipes
if: steps.affected.outputs.validate_all == 'true'
run: |
set -euo pipefail
# Full-repo enumeration: list every path under core/, contrib/,
# and skills/ (any depth), then feed it through the same helper
# the diff step uses — with --language python. This keeps one
# source of truth for the layout matrix (path → recipe →
# language) instead of re-implementing it in shell. `skills/`
# is included so that a Python skill at
# skills/<vertical>/<solution>/ is picked up by the full scan.
# Its language comes from manifest.language rather than the path:
# the middle component under skills/ is a vertical (retail/, hr/),
# not a language. Missing roots are tolerated via 2>/dev/null.
# NOT `--no-project`: see the notes on the sibling invocation
# above.
ALL=$(find core contrib skills -type f 2>/dev/null | uv run python tools/affected_recipes.py --language python)
echo "All Python recipes:"
echo "$ALL"
{
echo "all_recipes<<RECIPES_EOF"
echo "$ALL"
echo "RECIPES_EOF"
} >> "$GITHUB_OUTPUT"
- name: Run Python-specific checks per recipe
env:
VALIDATE_ALL: ${{ steps.affected.outputs.validate_all }}
ALL_RECIPES: ${{ steps.all_recipes.outputs.all_recipes }}
CHANGED_RECIPES: ${{ steps.affected.outputs.changed_recipes }}
run: |
set -euo pipefail
if [ "$VALIDATE_ALL" = "true" ]; then
RECIPES="$ALL_RECIPES"
else
RECIPES="$CHANGED_RECIPES"
fi
if [ -z "$RECIPES" ]; then
echo "[INFO] No Python recipe directories to validate."
exit 0
fi
FAILED=0
CI_FAULTS=0
# Each recipe is validated in a subshell so a transient failure
# inside one recipe (e.g. a `find` error) aborts only that
# recipe's checks, not the whole loop.
#
# The subshell's exit status carries WHOSE fault it was, because
# a subshell cannot write back to the parent's variables:
# 0 — recipe passed
# 1 — the recipe has problems the contributor must fix
# 2 — one of OUR checkers crashed or the CI environment broke
# Keeping 2 distinct is the point: a contributor should never be
# sent hunting through their own PR for a bug in our tooling.
while IFS= read -r recipe; do
[ -z "$recipe" ] && continue
echo ""
echo "========================================"
echo "Validating: $recipe"
echo "========================================"
# `set +e` here only so the parent can READ the subshell's exit
# status; without it `set -e` would abort this script the moment
# a recipe fails, before the case below can classify it. The
# subshell re-enables `set -e` for itself on its first line, so
# an unexpected failure mid-recipe still aborts that recipe's
# remaining checks exactly as before.
set +e
(
set -e
RECIPE_FAILED=0
CI_FAULT=0
# ------------------------------------------------------------
# Check: .env.example — notice keys
#
# Emits ::notice (never fail) when a common configuration
# key is not declared. These are conventionally used by GCP
# runtimes or ADK internals and their absence isn't always
# an error, so this is advisory only.
# ------------------------------------------------------------
NOTICE_ENV_KEYS=(
"GOOGLE_CLOUD_PROJECT"
"GOOGLE_CLOUD_LOCATION"
"MODEL_NAME"
)
if [ -f "$recipe/.env.example" ]; then
for key in "${NOTICE_ENV_KEYS[@]}"; do
if ! grep -qE "^(export\s+)?${key}(_[A-Z0-9_]*)?\s*=" "$recipe/.env.example"; then
echo "::notice file=$recipe/.env.example::No environment variable starting with '${key}' found in .env.example. Consider adding one if your recipe uses it."
echo "[NOTICE] No env key with prefix '$key' in .env.example (not required)."
else
echo "[PASS] Found env key in .env.example: $key"
fi
done
fi
# ------------------------------------------------------------
# Check: env vars used in Python source but missing from
# .env.example. Delegated to check_env_vars.py, which
# AST-parses the recipe's Python source — handles multi-line
# calls, import aliases (from os import getenv), and
# suppresses false positives for OS/CI variables
# (HOME, PATH, GITHUB_*, INTEGRATION_TEST, …).
# ------------------------------------------------------------
if [ -f "$recipe/.env.example" ]; then
# Output is NOT captured: the checker prints its own human
# blocks and its own ::error annotations. Capturing would
# swallow the annotations, and re-echoing them through a
# shell demux is what used to flatten every multi-line fix
# instruction into one unusable line.
set +e
uv run --no-project \
python3 .github/scripts/check_env_vars.py "$recipe"
env_vars_exit=$?
set -e
case "$env_vars_exit" in
0) ;;
1) RECIPE_FAILED=1 ;;
2) CI_FAULT=1 ;;
*)
echo "::error::check_env_vars.py exited with an undefined status ($env_vars_exit). This is a CI tooling bug, not a problem with your recipe."
CI_FAULT=1
;;
esac
fi
# ------------------------------------------------------------
# Check: recipe must not carry its own Ruff configuration.
# Ruff configuration is centralized in the repo root
# pyproject.toml.
#
# Ruff discovers configuration from three sources (whichever
# is nearest walking up from a target file):
# 1. `[tool.ruff*]` tables in pyproject.toml
# 2. a `ruff.toml` file
# 3. a `.ruff.toml` file
# All three must be blocked in recipe directories, or
# centralisation has a hole. `ruff.toml` and `.ruff.toml`
# are searched over the whole recipe subtree because ruff
# walks up per-file, so a nested copy would still override
# the root config for files below it.
# ------------------------------------------------------------
pyproject="$recipe/pyproject.toml"
ruff_check_passed=1
if [ -f "$pyproject" ] && grep -qE '^\s*\[tool\.ruff(\.|\])' "$pyproject"; then
echo "::error file=$pyproject::Recipe pyproject.toml must not declare a [tool.ruff*] block. Ruff configuration is centralized in the repo root pyproject.toml (see [tool.ruff] there). Remove all [tool.ruff*] tables from this file."
echo "[FAIL] pyproject.toml contains a [tool.ruff*] block — remove it (see root pyproject.toml)."
RECIPE_FAILED=1
ruff_check_passed=0
fi
standalone_ruff_configs=$(find "$recipe" -type f \
\( -name 'ruff.toml' -o -name '.ruff.toml' \) 2>/dev/null || true)
if [ -n "$standalone_ruff_configs" ]; then
while IFS= read -r ruff_cfg; do
[ -z "$ruff_cfg" ] && continue
echo "::error file=$ruff_cfg::Recipe must not contain a standalone ruff.toml or .ruff.toml. Ruff configuration is centralized in the repo root pyproject.toml. Delete this file."
echo "[FAIL] Standalone Ruff config found: $ruff_cfg — delete it (see root pyproject.toml)."
done <<< "$standalone_ruff_configs"
RECIPE_FAILED=1
ruff_check_passed=0
fi
if [ "$ruff_check_passed" -eq 1 ]; then
echo "[PASS] Recipe carries no local Ruff configuration."
fi
# ------------------------------------------------------------
# Check: [project] metadata rules. Delegated to
# check_recipe_pyproject.py:
# - project-name-matches-folder: [project].name must
# equal the recipe folder basename.
# - python-version-floor: [project].requires-python must
# not permit Python versions below 3.11.
# - description-matches-manifest: if [project].description
# is set, it must equal manifest.description (after
# .strip(), exact match).
# ------------------------------------------------------------
if [ -f "$pyproject" ]; then
# tomllib is stdlib on Python >= 3.11 (setup-uv installs
# 3.11); pyyaml + packaging are pulled into an ephemeral
# env via `uv run --with`. `packaging` is used for PEP
# 440 version specifier parsing (see
# check_requires_python in the checker).
# Uncaptured for the same reason as check_env_vars.py above.
set +e
uv run --no-project --with pyyaml --with packaging \
python .github/scripts/check_recipe_pyproject.py "$recipe"
py_exit=$?
set -e
case "$py_exit" in
0) ;;
1) RECIPE_FAILED=1 ;;
2) CI_FAULT=1 ;;
*)
echo "::error::check_recipe_pyproject.py exited with an undefined status ($py_exit). This is a CI tooling bug, not a problem with your recipe."
CI_FAULT=1
;;
esac
fi
# ------------------------------------------------------------
# Notice: Possible hardcoded model names in Python files.
# Emits ::notice (never fail) when a string literal in a
# non-test .py file matches a well-known model prefix.
# ------------------------------------------------------------
MODEL_PREFIXES="gemini-|gemini-exp-|imagen-|claude-|llama-|meta/llama-|mistral-|codestral-|phi-|grok-|command-|jamba-"
while IFS= read -r pyfile; do
matches=$(grep -onE "['\"]($MODEL_PREFIXES)[^ '\"]{1,35}['\"]" "$pyfile" || true)
[ -z "$matches" ] && continue
while IFS=: read -r lineno match; do
[ -z "$lineno" ] && continue
echo "::notice file=$pyfile,line=$lineno::Possible hardcoded model name $match detected. If this is a model identifier, consider using an environment variable instead."
echo "[NOTICE] $pyfile:$lineno — possible hardcoded model name: $match"
done <<< "$matches"
done < <(find "$recipe" -type f -name "*.py" ! -path "*/tests/*")
# A CI fault outranks a recipe failure: if our own checker
# could not run, we do not actually know whether the recipe
# is valid, and saying "your recipe is broken" would be a
# guess.
if [ "$CI_FAULT" -eq 1 ]; then
exit 2
fi
if [ "$RECIPE_FAILED" -eq 1 ]; then
exit 1
fi
echo ""
echo "[PASS] All Python-specific checks passed for: $recipe"
)
recipe_status=$?
set -e
case "$recipe_status" in
0) ;;
2) CI_FAULTS=1 ;;
*) FAILED=1 ;;
esac
done <<< "$RECIPES"
if [ "$CI_FAULTS" -eq 1 ]; then
echo ""
echo "========================================"
echo " CI TOOLING FAILURE — not your changes"
echo "========================================"
echo ""
echo "One of this repo's own checker scripts could not run, so"
echo "validation is inconclusive. Do not change your recipe to"
echo "work around this."
echo ""
echo " 1. Re-run this job — transient failures clear on retry."
echo " 2. If it fails again, open an issue with the [ci-fault]"
echo " detail above and a link to this run."
echo ""
echo "See docs/recipe-handbook/troubleshooting.md#ci-infrastructure-failure"
exit 1
fi
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "========================================"
echo " ACTION REQUIRED: recipe validation failed"
echo "========================================"
echo ""
echo "Each problem above lists why the rule applies and how to"
echo "fix it. Every fix is documented in"
echo "docs/recipe-handbook/troubleshooting.md"
exit 1
fi
echo ""
echo "[PASS] All Python recipes passed Python-specific validation."