diff --git a/.github/scripts/release-gate.sh b/.github/scripts/release-gate.sh index 616e2f64..3ed00047 100644 --- a/.github/scripts/release-gate.sh +++ b/.github/scripts/release-gate.sh @@ -10,7 +10,8 @@ # # Env: GH_TOKEN, SHA, GITHUB_REPOSITORY. Optional: BEFORE (the push's previous # head, to diff the whole push), GATE_EVENT (default push), GATE_TIMEOUT seconds -# for Validate (default 1500), SMOKE_TIMEOUT seconds (default 3600), GATE_POLL +# for Validate (default 1500), SMOKE_TIMEOUT seconds (default 5400: the smoke +# job's 75-minute timeout-minutes plus 15 minutes for queueing), GATE_POLL # seconds (default 20). set -euo pipefail @@ -45,7 +46,10 @@ wait_green() { # smoke_applies: 0 when some changed file falls outside blender-smoke.yml's # paths-ignore ("**.md", "docs/**", "assets/**"). Unreadable file lists count -# as "applies" so the gate fails toward waiting, not toward skipping. +# as "applies" so the gate fails toward waiting, not toward skipping. So do +# lists of 300 or more: the compare and commit APIs return at most 300 files, +# in path order, so a big docs/gallery regeneration can hide the code change +# that follows it alphabetically (#365). smoke_applies() { local files if [ -n "${BEFORE:-}" ] && [ "${BEFORE}" != "0000000000000000000000000000000000000000" ]; then @@ -54,6 +58,7 @@ smoke_applies() { files=$(gh api "repos/$repo/commits/$sha" --jq '.files[].filename' 2>/dev/null) || return 0 fi [ -n "$files" ] || return 0 + [ "$(printf '%s\n' "$files" | wc -l)" -lt 300 ] || return 0 while IFS= read -r f; do case "$f" in *.md|docs/*|assets/*) ;; @@ -68,7 +73,7 @@ wait_green validate.yml Validate "${GATE_TIMEOUT:-1500}" pr=$(gh api "repos/$repo/commits/$sha/pulls" --jq '.[0].number // empty') if [ -z "$pr" ]; then if smoke_applies; then - wait_green blender-smoke.yml "Blender Smoke" "${SMOKE_TIMEOUT:-3600}" + wait_green blender-smoke.yml "Blender Smoke" "${SMOKE_TIMEOUT:-5400}" else echo "Direct push changed only smoke-ignored paths; Validate alone gates this release" fi diff --git a/.github/workflows/drift-check.yml b/.github/workflows/drift-check.yml index 45cee3d3..4a7b2178 100644 --- a/.github/workflows/drift-check.yml +++ b/.github/workflows/drift-check.yml @@ -19,3 +19,7 @@ jobs: with: mode: self format: gh-summary + # The action checks out and runs the meta-repo's Python at this ref. + # Its default is the floating `v1` tag, so pin the same commit as the + # action itself (v1 and v1.19.2 both resolve to it). + meta-repo-ref: 7886cbe6ef93d57cc73c020b98268fa0dc0bdc98 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bac838ba..81963165 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,7 +142,9 @@ jobs: with: plugin-version: ${{ steps.new.outputs.version }} previous-version: ${{ steps.current.outputs.version }} - meta-repo-ref: v1.15.1 + # A SHA, not a tag: this job holds contents:write and runs the + # meta-repo's Python at this ref. 9be79799 is tag v1.15.1. + meta-repo-ref: 9be79799df00ed42a0c4cff39e74a383a493296c - name: Write release notes (CHANGELOG entry and release body) # release-doc-sync prepends a "See release notes ... for details." stub; diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d3a8b991..a34c70c5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -79,6 +79,9 @@ jobs: - name: Showcase helper drift (warn-only report) run: python3 tests/check_helper_drift.py + - name: Check asset_sheet.py can render every showcase piece and reference + run: python3 tests/check_asset_sheet_select.py + - name: Check falsifiers declare the budget they target run: python3 tests/check_falsifier_targets.py @@ -287,6 +290,15 @@ jobs: echo "::error::pin these actions to a full commit SHA with a trailing '# vX.Y' comment (#306)" exit 1 fi + # Composite actions that check out another repo's code at an input ref + # must get a SHA too, or the pin above stops at the first hop (#363). + refs=$(grep -rnE '^\s*meta-repo-ref:' .github/workflows \ + | grep -vE 'meta-repo-ref:\s+[0-9a-f]{40}(\s|$)' || true) + if [ -n "$refs" ]; then + echo "$refs" + echo "::error::pass meta-repo-ref as a full commit SHA, not a tag (#363)" + exit 1 + fi - name: Check CLAUDE.md carries no personal plugin routing block run: | diff --git a/scripts/asset_sheet.py b/scripts/asset_sheet.py index e69bae6a..332a9a60 100644 --- a/scripts/asset_sheet.py +++ b/scripts/asset_sheet.py @@ -64,6 +64,7 @@ "bookshelf": (r"^BookshelfLow$", None), "book-trolley": (r"^TrolleyLow$", None), "brazier": (r"^BrazierLow$", None), + "butter-churn": (r"^ChurnLow$", None), "grain-sacks": (r"^SacksLow$", None), "rope-bridge": (r"^BridgeLow$", None), "wheelbarrow": (r"^BarrowLow$", None), diff --git a/skills/vse-python/SKILL.md b/skills/vse-python/SKILL.md index 1fbc4bd0..a4b7c5a1 100644 --- a/skills/vse-python/SKILL.md +++ b/skills/vse-python/SKILL.md @@ -147,7 +147,7 @@ A lone COLOR strip **does** honor `transform.scale_*` on 5.2. The break is media - 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+). - Example [`examples/vse-gamma-cross/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/vse-gamma-cross) — `GAMMA_CROSS` blend curve. -- Audit log: `docs/technical-audit.md` § Findings the release notes did not list. +- 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. ## References diff --git a/tests/check_asset_sheet_select.py b/tests/check_asset_sheet_select.py new file mode 100644 index 00000000..0e215a43 --- /dev/null +++ b/tests/check_asset_sheet_select.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Every showcase piece and asset-sheet reference has a SELECT row (#367). + +scripts/asset_sheet.py renders a candidate's hero asset by the regexes in its +SELECT table and refuses any name without a row, so a piece added without one +leaves its committed asset sheet impossible to regenerate. butter-churn shipped +that way. This reads SELECT with ast (no Pillow needed) and requires a row for: + +- every piece in showcase/gallery.json, and +- every name in the CLAUDE.md asset-quality reference set (read the same way + asset_sheet.reference_set() reads it). + + python tests/check_asset_sheet_select.py (exit 0 ok, 1 on a gap) +""" +from __future__ import annotations + +import ast +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def select_keys(root: Path) -> set[str]: + tree = ast.parse((root / "scripts" / "asset_sheet.py").read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "SELECT" for t in node.targets): + return {k.value for k in node.value.keys if isinstance(k, ast.Constant)} + raise SystemExit("scripts/asset_sheet.py has no SELECT table") + + +def reference_set(root: Path) -> list[str]: + src = (root / "scripts" / "asset_sheet.py").read_text(encoding="utf-8") + pattern = re.search(r'_REF_LINE = re\.compile\(r"(.+?)", re\.S\)', src).group(1) + m = re.search(pattern, (root / "CLAUDE.md").read_text(encoding="utf-8"), re.S) + return re.findall(r"`([a-z0-9-]+)`", m.group(1)) if m else [] + + +def check(root: Path = ROOT) -> list[str]: + keys = select_keys(root) + pieces = [p["name"] for p in json.loads( + (root / "showcase" / "gallery.json").read_text(encoding="utf-8"))["pieces"]] + refs = reference_set(root) + errors = [f"showcase piece {n!r} has no SELECT row in scripts/asset_sheet.py" + for n in pieces if n not in keys] + errors += [f"asset-sheet reference {n!r} (CLAUDE.md) has no SELECT row" + for n in refs if n not in keys] + if not refs: + errors.append("could not read the asset-sheet reference set from CLAUDE.md") + return errors + + +def main() -> int: + errors = check() + for e in errors: + print(f"::error::{e}", file=sys.stderr) + if not errors: + print("asset_sheet.py SELECT covers every showcase piece and reference") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/check_site_links.py b/tests/check_site_links.py index 653e220a..4c4a2d7c 100644 --- a/tests/check_site_links.py +++ b/tests/check_site_links.py @@ -32,6 +32,9 @@ REPO = Path(__file__).resolve().parent.parent DOCS = REPO / "docs" +# Tags whose href/src load or link a resource. iframe/video/audio/source/embed +# were once skipped, so a missing embedded file passed (#366). +REF_TAGS = ("a", "img", "link", "script", "iframe", "video", "audio", "source", "embed") class PageScan(HTMLParser): @@ -50,7 +53,7 @@ def handle_starttag(self, tag, attrs): if tag == "a" and a.get("name"): self.ids.add(a["name"]) for key in ("href", "src"): - if a.get(key) and tag in ("a", "img", "link", "script"): + if a.get(key) and tag in REF_TAGS: self.refs.append((line, key, a[key])) # Each srcset candidate is "URL [descriptor]"; every URL must resolve. if a.get("srcset") and tag in ("img", "source"): @@ -131,6 +134,12 @@ def main(argv: list[str] | None = None) -> int: if target is None: continue n_refs += 1 + if DOCS not in target.parents and target != DOCS: + # A relative path that climbs out of the site resolves to a repo + # file locally (CI builds _site/ inside the checkout) but 404s + # once deployed (#366). + failures.append(f"{rel}:{line}: {key}={url!r} -> escapes the site root") + continue if not target.is_file(): failures.append(f"{rel}:{line}: {key}={url!r} -> missing {target}") continue diff --git a/tests/check_skill_refs.py b/tests/check_skill_refs.py index 6832524f..7672805d 100644 --- a/tests/check_skill_refs.py +++ b/tests/check_skill_refs.py @@ -8,8 +8,10 @@ repository, and that link must name a path that exists. Fails on: -- a backticked bare repo path (`snippets/...`, `examples/...`, - `templates/...`, `rules/...`) that is not the text of such a link; +- a backticked bare repo path (`snippets/...`, `docs/...`, or any other + top-level directory of this repo) that is not the text of such a link; +- a relative markdown link (`](../../snippets/x.py)`, `](docs/x.md)`) that + climbs into the repo instead of linking absolutely; - a github.com/TMHSDigital/Blender-Developer-Tools blob/tree URL whose path is missing from the tree. @@ -24,7 +26,13 @@ ROOT = Path(__file__).resolve().parent.parent URL = "https://github.com/TMHSDigital/Blender-Developer-Tools/" LINK = re.compile(re.escape(URL) + r"(?:blob|tree)/main/([^)\s#]+)") -BARE = re.compile(r"(? list[Path]: @@ -39,6 +47,9 @@ def check() -> list[str]: for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1): for m in BARE.finditer(line): errors.append(f"{rel}:{i}: bare repo path `{m.group(1)}` (link it to {URL}...)") + for m in REL_LINK.finditer(line): + errors.append(f"{rel}:{i}: relative link ({m.group(1)}) breaks outside a checkout " + f"(link it to {URL}...)") for m in LINK.finditer(line): if not (ROOT / m.group(1)).exists(): errors.append(f"{rel}:{i}: link to missing path {m.group(1)}") diff --git a/tests/test_release_gate.py b/tests/test_release_gate.py index fb76b551..6a3c0803 100644 --- a/tests/test_release_gate.py +++ b/tests/test_release_gate.py @@ -86,6 +86,20 @@ def test_docs_only_direct_push_skips_smoke(self): self.assertEqual(code, 0, out) self.assertIn("only smoke-ignored paths", out) + def test_truncated_file_list_waits_for_smoke(self): + # 300 docs paths is the API's cap: a code change may sort after them. + many = r"\n".join(f"docs/gallery/p{i:03d}.html" for i in range(300)) + code, out = self.gate(FAKE_PR="", FAKE_SMOKE="none none", SMOKE_TIMEOUT="0", + FAKE_FILES=many) + self.assertEqual(code, 1) + self.assertIn("Blender Smoke did not finish", out) + + def test_299_docs_files_still_skip_smoke(self): + many = r"\n".join(f"docs/gallery/p{i:03d}.html" for i in range(299)) + code, out = self.gate(FAKE_PR="", FAKE_SMOKE="none none", FAKE_FILES=many) + self.assertEqual(code, 0, out) + self.assertIn("only smoke-ignored paths", out) + def test_red_validate_blocks(self): code, out = self.gate(FAKE_RUN="completed failure") self.assertEqual(code, 1)