Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/scripts/release-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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/*) ;;
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/drift-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions scripts/asset_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion skills/vse-python/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
66 changes: 66 additions & 0 deletions tests/check_asset_sheet_select.py
Original file line number Diff line number Diff line change
@@ -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())
11 changes: 10 additions & 1 deletion tests/check_site_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"):
Expand Down Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions tests/check_skill_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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"(?<!\[)`((?:snippets|examples|templates|rules)/[^`]*)`(?!\]\()")
# Every tracked top-level directory, not a hand-kept list: the old list of four
# missed `docs/technical-audit.md` in vse-python (#395).
TOP_DIRS = sorted(p.name for p in ROOT.iterdir()
if p.is_dir() and not p.name.startswith((".", "_")))
BARE = re.compile(r"(?<!\[)`((?:" + "|".join(map(re.escape, TOP_DIRS)) + r")/[^`]*)`(?!\]\()")
REL_LINK = re.compile(r"\]\(((?:\.\./)+[^)\s#]+|(?:" + "|".join(map(re.escape, TOP_DIRS))
+ r")/[^)\s#]*)\)")


def files() -> list[Path]:
Expand All @@ -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)}")
Expand Down
14 changes: 14 additions & 0 deletions tests/test_release_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading