Skip to content

Commit 536f60f

Browse files
TMHSDigitalclaude
andauthored
fix(ci): close five gates that checked less than they claimed (#419)
- release-gate.sh (#365): the compare/commit APIs return at most 300 files in path order, so a large docs/gallery regeneration could hide a later code change and the gate released on Validate alone. A list of 300+ files now counts as "smoke applies". SMOKE_TIMEOUT defaults to 5400 s (the job's 75 minutes plus queueing) instead of 3600. - drift-check.yml / release.yml (#363): both composite actions check out and run the meta-repo's Python at meta-repo-ref, which was the floating v1 tag and the v1.15.1 tag. Both are now commit SHAs, and the Validate pin check rejects a tag meta-repo-ref. - check_site_links.py (#366): a relative link that climbs out of the site resolved to a repo file in CI and passed; it now fails. src on iframe, video, audio, source and embed is checked too. - check_skill_refs.py (#395): bare-path detection covered four directories, so `docs/technical-audit.md` in vse-python shipped as a dead path to plugin users. It now covers every top-level directory and relative markdown links; the vse-python reference is linked. - asset_sheet.py (#367): butter-churn had no SELECT row, so its committed asset sheet could not be regenerated. Row added, and tests/check_asset_sheet_select.py (run by Validate) requires a row for every showcase piece and every CLAUDE.md reference. Closes #363 Closes #365 Closes #366 Closes #367 Closes #395 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ace1fae commit 536f60f

10 files changed

Lines changed: 133 additions & 9 deletions

File tree

‎.github/scripts/release-gate.sh‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
#
1111
# Env: GH_TOKEN, SHA, GITHUB_REPOSITORY. Optional: BEFORE (the push's previous
1212
# head, to diff the whole push), GATE_EVENT (default push), GATE_TIMEOUT seconds
13-
# for Validate (default 1500), SMOKE_TIMEOUT seconds (default 3600), GATE_POLL
13+
# for Validate (default 1500), SMOKE_TIMEOUT seconds (default 5400: the smoke
14+
# job's 75-minute timeout-minutes plus 15 minutes for queueing), GATE_POLL
1415
# seconds (default 20).
1516
set -euo pipefail
1617

@@ -45,7 +46,10 @@ wait_green() {
4546

4647
# smoke_applies: 0 when some changed file falls outside blender-smoke.yml's
4748
# paths-ignore ("**.md", "docs/**", "assets/**"). Unreadable file lists count
48-
# as "applies" so the gate fails toward waiting, not toward skipping.
49+
# as "applies" so the gate fails toward waiting, not toward skipping. So do
50+
# lists of 300 or more: the compare and commit APIs return at most 300 files,
51+
# in path order, so a big docs/gallery regeneration can hide the code change
52+
# that follows it alphabetically (#365).
4953
smoke_applies() {
5054
local files
5155
if [ -n "${BEFORE:-}" ] && [ "${BEFORE}" != "0000000000000000000000000000000000000000" ]; then
@@ -54,6 +58,7 @@ smoke_applies() {
5458
files=$(gh api "repos/$repo/commits/$sha" --jq '.files[].filename' 2>/dev/null) || return 0
5559
fi
5660
[ -n "$files" ] || return 0
61+
[ "$(printf '%s\n' "$files" | wc -l)" -lt 300 ] || return 0
5762
while IFS= read -r f; do
5863
case "$f" in
5964
*.md|docs/*|assets/*) ;;
@@ -68,7 +73,7 @@ wait_green validate.yml Validate "${GATE_TIMEOUT:-1500}"
6873
pr=$(gh api "repos/$repo/commits/$sha/pulls" --jq '.[0].number // empty')
6974
if [ -z "$pr" ]; then
7075
if smoke_applies; then
71-
wait_green blender-smoke.yml "Blender Smoke" "${SMOKE_TIMEOUT:-3600}"
76+
wait_green blender-smoke.yml "Blender Smoke" "${SMOKE_TIMEOUT:-5400}"
7277
else
7378
echo "Direct push changed only smoke-ignored paths; Validate alone gates this release"
7479
fi

‎.github/workflows/drift-check.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ jobs:
1919
with:
2020
mode: self
2121
format: gh-summary
22+
# The action checks out and runs the meta-repo's Python at this ref.
23+
# Its default is the floating `v1` tag, so pin the same commit as the
24+
# action itself (v1 and v1.19.2 both resolve to it).
25+
meta-repo-ref: 7886cbe6ef93d57cc73c020b98268fa0dc0bdc98

‎.github/workflows/release.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ jobs:
142142
with:
143143
plugin-version: ${{ steps.new.outputs.version }}
144144
previous-version: ${{ steps.current.outputs.version }}
145-
meta-repo-ref: v1.15.1
145+
# A SHA, not a tag: this job holds contents:write and runs the
146+
# meta-repo's Python at this ref. 9be79799 is tag v1.15.1.
147+
meta-repo-ref: 9be79799df00ed42a0c4cff39e74a383a493296c
146148

147149
- name: Write release notes (CHANGELOG entry and release body)
148150
# release-doc-sync prepends a "See release notes ... for details." stub;

‎.github/workflows/validate.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ jobs:
7979
- name: Showcase helper drift (warn-only report)
8080
run: python3 tests/check_helper_drift.py
8181

82+
- name: Check asset_sheet.py can render every showcase piece and reference
83+
run: python3 tests/check_asset_sheet_select.py
84+
8285
- name: Check falsifiers declare the budget they target
8386
run: python3 tests/check_falsifier_targets.py
8487

@@ -287,6 +290,15 @@ jobs:
287290
echo "::error::pin these actions to a full commit SHA with a trailing '# vX.Y' comment (#306)"
288291
exit 1
289292
fi
293+
# Composite actions that check out another repo's code at an input ref
294+
# must get a SHA too, or the pin above stops at the first hop (#363).
295+
refs=$(grep -rnE '^\s*meta-repo-ref:' .github/workflows \
296+
| grep -vE 'meta-repo-ref:\s+[0-9a-f]{40}(\s|$)' || true)
297+
if [ -n "$refs" ]; then
298+
echo "$refs"
299+
echo "::error::pass meta-repo-ref as a full commit SHA, not a tag (#363)"
300+
exit 1
301+
fi
290302
291303
- name: Check CLAUDE.md carries no personal plugin routing block
292304
run: |

‎scripts/asset_sheet.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"bookshelf": (r"^BookshelfLow$", None),
6565
"book-trolley": (r"^TrolleyLow$", None),
6666
"brazier": (r"^BrazierLow$", None),
67+
"butter-churn": (r"^ChurnLow$", None),
6768
"grain-sacks": (r"^SacksLow$", None),
6869
"rope-bridge": (r"^BridgeLow$", None),
6970
"wheelbarrow": (r"^BarrowLow$", None),

‎skills/vse-python/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ A lone COLOR strip **does** honor `transform.scale_*` on 5.2. The break is media
147147

148148
- Example [`examples/vse-cut-list/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/vse-cut-list) — accessor rename, `new_effect` kwargs, save/reload, and the 5.2 COLOR size bake (`--check-pixels` asserts `A.width, A.height == render size` on 5.2+).
149149
- Example [`examples/vse-gamma-cross/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/vse-gamma-cross) — `GAMMA_CROSS` blend curve.
150-
- Audit log: `docs/technical-audit.md` § Findings the release notes did not list.
150+
- Audit log: [`docs/technical-audit.md`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/docs/technical-audit.md) § Findings the release notes did not list.
151151

152152
## References
153153

‎tests/check_asset_sheet_select.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env python3
2+
"""Every showcase piece and asset-sheet reference has a SELECT row (#367).
3+
4+
scripts/asset_sheet.py renders a candidate's hero asset by the regexes in its
5+
SELECT table and refuses any name without a row, so a piece added without one
6+
leaves its committed asset sheet impossible to regenerate. butter-churn shipped
7+
that way. This reads SELECT with ast (no Pillow needed) and requires a row for:
8+
9+
- every piece in showcase/gallery.json, and
10+
- every name in the CLAUDE.md asset-quality reference set (read the same way
11+
asset_sheet.reference_set() reads it).
12+
13+
python tests/check_asset_sheet_select.py (exit 0 ok, 1 on a gap)
14+
"""
15+
from __future__ import annotations
16+
17+
import ast
18+
import json
19+
import re
20+
import sys
21+
from pathlib import Path
22+
23+
ROOT = Path(__file__).resolve().parent.parent
24+
25+
26+
def select_keys(root: Path) -> set[str]:
27+
tree = ast.parse((root / "scripts" / "asset_sheet.py").read_text(encoding="utf-8"))
28+
for node in tree.body:
29+
if isinstance(node, ast.Assign) and any(
30+
isinstance(t, ast.Name) and t.id == "SELECT" for t in node.targets):
31+
return {k.value for k in node.value.keys if isinstance(k, ast.Constant)}
32+
raise SystemExit("scripts/asset_sheet.py has no SELECT table")
33+
34+
35+
def reference_set(root: Path) -> list[str]:
36+
src = (root / "scripts" / "asset_sheet.py").read_text(encoding="utf-8")
37+
pattern = re.search(r'_REF_LINE = re\.compile\(r"(.+?)", re\.S\)', src).group(1)
38+
m = re.search(pattern, (root / "CLAUDE.md").read_text(encoding="utf-8"), re.S)
39+
return re.findall(r"`([a-z0-9-]+)`", m.group(1)) if m else []
40+
41+
42+
def check(root: Path = ROOT) -> list[str]:
43+
keys = select_keys(root)
44+
pieces = [p["name"] for p in json.loads(
45+
(root / "showcase" / "gallery.json").read_text(encoding="utf-8"))["pieces"]]
46+
refs = reference_set(root)
47+
errors = [f"showcase piece {n!r} has no SELECT row in scripts/asset_sheet.py"
48+
for n in pieces if n not in keys]
49+
errors += [f"asset-sheet reference {n!r} (CLAUDE.md) has no SELECT row"
50+
for n in refs if n not in keys]
51+
if not refs:
52+
errors.append("could not read the asset-sheet reference set from CLAUDE.md")
53+
return errors
54+
55+
56+
def main() -> int:
57+
errors = check()
58+
for e in errors:
59+
print(f"::error::{e}", file=sys.stderr)
60+
if not errors:
61+
print("asset_sheet.py SELECT covers every showcase piece and reference")
62+
return 1 if errors else 0
63+
64+
65+
if __name__ == "__main__":
66+
sys.exit(main())

‎tests/check_site_links.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232

3333
REPO = Path(__file__).resolve().parent.parent
3434
DOCS = REPO / "docs"
35+
# Tags whose href/src load or link a resource. iframe/video/audio/source/embed
36+
# were once skipped, so a missing embedded file passed (#366).
37+
REF_TAGS = ("a", "img", "link", "script", "iframe", "video", "audio", "source", "embed")
3538

3639

3740
class PageScan(HTMLParser):
@@ -50,7 +53,7 @@ def handle_starttag(self, tag, attrs):
5053
if tag == "a" and a.get("name"):
5154
self.ids.add(a["name"])
5255
for key in ("href", "src"):
53-
if a.get(key) and tag in ("a", "img", "link", "script"):
56+
if a.get(key) and tag in REF_TAGS:
5457
self.refs.append((line, key, a[key]))
5558
# Each srcset candidate is "URL [descriptor]"; every URL must resolve.
5659
if a.get("srcset") and tag in ("img", "source"):
@@ -131,6 +134,12 @@ def main(argv: list[str] | None = None) -> int:
131134
if target is None:
132135
continue
133136
n_refs += 1
137+
if DOCS not in target.parents and target != DOCS:
138+
# A relative path that climbs out of the site resolves to a repo
139+
# file locally (CI builds _site/ inside the checkout) but 404s
140+
# once deployed (#366).
141+
failures.append(f"{rel}:{line}: {key}={url!r} -> escapes the site root")
142+
continue
134143
if not target.is_file():
135144
failures.append(f"{rel}:{line}: {key}={url!r} -> missing {target}")
136145
continue

‎tests/check_skill_refs.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
repository, and that link must name a path that exists.
99
1010
Fails on:
11-
- a backticked bare repo path (`snippets/...`, `examples/...`,
12-
`templates/...`, `rules/...`) that is not the text of such a link;
11+
- a backticked bare repo path (`snippets/...`, `docs/...`, or any other
12+
top-level directory of this repo) that is not the text of such a link;
13+
- a relative markdown link (`](../../snippets/x.py)`, `](docs/x.md)`) that
14+
climbs into the repo instead of linking absolutely;
1315
- a github.com/TMHSDigital/Blender-Developer-Tools blob/tree URL whose path
1416
is missing from the tree.
1517
@@ -24,7 +26,13 @@
2426
ROOT = Path(__file__).resolve().parent.parent
2527
URL = "https://github.com/TMHSDigital/Blender-Developer-Tools/"
2628
LINK = re.compile(re.escape(URL) + r"(?:blob|tree)/main/([^)\s#]+)")
27-
BARE = re.compile(r"(?<!\[)`((?:snippets|examples|templates|rules)/[^`]*)`(?!\]\()")
29+
# Every tracked top-level directory, not a hand-kept list: the old list of four
30+
# missed `docs/technical-audit.md` in vse-python (#395).
31+
TOP_DIRS = sorted(p.name for p in ROOT.iterdir()
32+
if p.is_dir() and not p.name.startswith((".", "_")))
33+
BARE = re.compile(r"(?<!\[)`((?:" + "|".join(map(re.escape, TOP_DIRS)) + r")/[^`]*)`(?!\]\()")
34+
REL_LINK = re.compile(r"\]\(((?:\.\./)+[^)\s#]+|(?:" + "|".join(map(re.escape, TOP_DIRS))
35+
+ r")/[^)\s#]*)\)")
2836

2937

3038
def files() -> list[Path]:
@@ -39,6 +47,9 @@ def check() -> list[str]:
3947
for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
4048
for m in BARE.finditer(line):
4149
errors.append(f"{rel}:{i}: bare repo path `{m.group(1)}` (link it to {URL}...)")
50+
for m in REL_LINK.finditer(line):
51+
errors.append(f"{rel}:{i}: relative link ({m.group(1)}) breaks outside a checkout "
52+
f"(link it to {URL}...)")
4253
for m in LINK.finditer(line):
4354
if not (ROOT / m.group(1)).exists():
4455
errors.append(f"{rel}:{i}: link to missing path {m.group(1)}")

‎tests/test_release_gate.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,20 @@ def test_docs_only_direct_push_skips_smoke(self):
8686
self.assertEqual(code, 0, out)
8787
self.assertIn("only smoke-ignored paths", out)
8888

89+
def test_truncated_file_list_waits_for_smoke(self):
90+
# 300 docs paths is the API's cap: a code change may sort after them.
91+
many = r"\n".join(f"docs/gallery/p{i:03d}.html" for i in range(300))
92+
code, out = self.gate(FAKE_PR="", FAKE_SMOKE="none none", SMOKE_TIMEOUT="0",
93+
FAKE_FILES=many)
94+
self.assertEqual(code, 1)
95+
self.assertIn("Blender Smoke did not finish", out)
96+
97+
def test_299_docs_files_still_skip_smoke(self):
98+
many = r"\n".join(f"docs/gallery/p{i:03d}.html" for i in range(299))
99+
code, out = self.gate(FAKE_PR="", FAKE_SMOKE="none none", FAKE_FILES=many)
100+
self.assertEqual(code, 0, out)
101+
self.assertIn("only smoke-ignored paths", out)
102+
89103
def test_red_validate_blocks(self):
90104
code, out = self.gate(FAKE_RUN="completed failure")
91105
self.assertEqual(code, 1)

0 commit comments

Comments
 (0)