Skip to content

Commit 10fcd33

Browse files
TMHSDigitalclaude
andcommitted
ci: run Blender Smoke on push to main and gate direct-push releases on it
Showcase pieces land as direct pushes, and the release gate let a push with no PR through on Validate alone. Blender Smoke had no push trigger, so a script broken on 4.5 was tagged and released and only went red on the Monday cron. - blender-smoke.yml: push to main triggers the 5.2 + 4.5 matrix with the same paths-ignore as PRs. Push runs group per SHA so a later push never cancels the run an earlier release is waiting on. - release-gate.sh: the Validate poll is now wait_green(); a direct push also waits (SMOKE_TIMEOUT, default 3600 s) for a successful Blender Smoke push run on $SHA, unless every file in the push (compare BEFORE...SHA) matches the smoke paths-ignore, in which case no run exists and Validate gates alone. An unreadable file list counts as "smoke applies" (fail toward waiting). - release.yml passes BEFORE=github.event.before. - tests/test_release_gate.py: 4 new cases (green smoke, red smoke, no smoke run -> timeout, docs-only push skips). 10/10 OK. Falsified: making smoke_applies always false lets 3 direct-push cases through (3 failures); restored, 10/10 OK. Closes #321 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a06d557 commit 10fcd33

4 files changed

Lines changed: 105 additions & 32 deletions

File tree

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

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,75 @@
33
#
44
# 1. Validate (push event) finished and succeeded on exactly this SHA.
55
# 2. If the SHA was merged from a PR, every check on that PR passed or was
6-
# skipped. Blender Smoke runs on PRs only, so this is its evidence.
6+
# skipped (that includes the PR's Blender Smoke run).
7+
# 3. If it was a direct push, Blender Smoke (push event) finished and
8+
# succeeded on this SHA -- unless every file the push changed matches the
9+
# workflow's paths-ignore, in which case no smoke run exists to wait for.
710
#
8-
# Env: GH_TOKEN, SHA, GITHUB_REPOSITORY. Optional: GATE_EVENT (default push),
9-
# GATE_TIMEOUT seconds (default 1500), GATE_POLL seconds (default 20).
11+
# Env: GH_TOKEN, SHA, GITHUB_REPOSITORY. Optional: BEFORE (the push's previous
12+
# 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
14+
# seconds (default 20).
1015
set -euo pipefail
1116

1217
repo="${GITHUB_REPOSITORY:?}"
1318
sha="${SHA:?}"
1419
event="${GATE_EVENT:-push}"
15-
deadline=$((SECONDS + ${GATE_TIMEOUT:-1500}))
1620

17-
while :; do
18-
read -r status conclusion < <(
19-
gh run list --repo "$repo" --workflow validate.yml --commit "$sha" \
20-
--event "$event" --limit 1 --json status,conclusion \
21-
--jq '.[0] // {} | "\(.status // "none") \(.conclusion // "none")"'
22-
)
23-
[ "$status" = "completed" ] && break
24-
if [ "$SECONDS" -ge "$deadline" ]; then
25-
echo "::error::Validate did not finish for $sha (last status: $status)"
21+
# wait_green WORKFLOW_FILE LABEL TIMEOUT: poll until the newest run of the
22+
# workflow on $sha completes, then require success.
23+
wait_green() {
24+
local wf="$1" label="$2" deadline=$((SECONDS + $3)) status conclusion
25+
while :; do
26+
read -r status conclusion < <(
27+
gh run list --repo "$repo" --workflow "$wf" --commit "$sha" \
28+
--event "$event" --limit 1 --json status,conclusion \
29+
--jq '.[0] // {} | "\(.status // "none") \(.conclusion // "none")"'
30+
)
31+
[ "$status" = "completed" ] && break
32+
if [ "$SECONDS" -ge "$deadline" ]; then
33+
echo "::error::$label did not finish for $sha (last status: $status)"
34+
exit 1
35+
fi
36+
echo "$label status for $sha: $status; waiting"
37+
sleep "${GATE_POLL:-20}"
38+
done
39+
if [ "$conclusion" != "success" ]; then
40+
echo "::error::$label concluded '$conclusion' for $sha; not releasing"
2641
exit 1
2742
fi
28-
echo "Validate status for $sha: $status; waiting"
29-
sleep "${GATE_POLL:-20}"
30-
done
43+
echo "$label: success"
44+
}
3145

32-
if [ "$conclusion" != "success" ]; then
33-
echo "::error::Validate concluded '$conclusion' for $sha; not releasing"
34-
exit 1
35-
fi
36-
echo "Validate: success"
46+
# smoke_applies: 0 when some changed file falls outside blender-smoke.yml's
47+
# paths-ignore ("**.md", "docs/**", "assets/**"). Unreadable file lists count
48+
# as "applies" so the gate fails toward waiting, not toward skipping.
49+
smoke_applies() {
50+
local files
51+
if [ -n "${BEFORE:-}" ] && [ "${BEFORE}" != "0000000000000000000000000000000000000000" ]; then
52+
files=$(gh api "repos/$repo/compare/$BEFORE...$sha" --jq '.files[].filename' 2>/dev/null) || return 0
53+
else
54+
files=$(gh api "repos/$repo/commits/$sha" --jq '.files[].filename' 2>/dev/null) || return 0
55+
fi
56+
[ -n "$files" ] || return 0
57+
while IFS= read -r f; do
58+
case "$f" in
59+
*.md|docs/*|assets/*) ;;
60+
*) return 0 ;;
61+
esac
62+
done <<< "$files"
63+
return 1
64+
}
65+
66+
wait_green validate.yml Validate "${GATE_TIMEOUT:-1500}"
3767

3868
pr=$(gh api "repos/$repo/commits/$sha/pulls" --jq '.[0].number // empty')
3969
if [ -z "$pr" ]; then
40-
echo "No PR is associated with $sha (direct push); Validate alone gates this release"
70+
if smoke_applies; then
71+
wait_green blender-smoke.yml "Blender Smoke" "${SMOKE_TIMEOUT:-3600}"
72+
else
73+
echo "Direct push changed only smoke-ignored paths; Validate alone gates this release"
74+
fi
4175
exit 0
4276
fi
4377

‎.github/workflows/blender-smoke.yml‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
name: Blender Smoke Test
22

33
# Executes the snippets' and skills' headline examples inside REAL Blender, headless,
4-
# on the current stable (5.2.x LTS) and the fallback LTS (4.5.x) on every PR,
4+
# on the current stable (5.2.x LTS) and the fallback LTS (4.5.x) on every PR and
5+
# every push to main (direct pushes are released, and release-gate.sh waits for
6+
# this run on the pushed SHA),
57
# plus 5.1.x on the weekly cron, the opt-in `needs-5.1` PR label, or a
68
# workflow_dispatch series input, and fails on any error or
79
# empty-output assertion. Shipped examples go through tests/smoke/run_example.py
@@ -30,6 +32,14 @@ on:
3032
default: "5.2"
3133
schedule:
3234
- cron: "0 7 * * 1" # weekly, Monday 07:00 UTC
35+
push:
36+
branches: [main]
37+
# Same filter as pull_request below. release-gate.sh mirrors these globs to
38+
# decide whether a push has a smoke run to wait for; keep them in step.
39+
paths-ignore:
40+
- "**.md"
41+
- "docs/**"
42+
- "assets/**"
3343
pull_request:
3444
branches: [main]
3545
types: [opened, synchronize, reopened, labeled]
@@ -46,8 +56,9 @@ permissions:
4656
concurrency:
4757
# Isolate labeled runs so auto-label (`ci`, `documentation`, ...) does not
4858
# cancel the default 4.5/5.2 jobs. synchronize still cancels the previous
49-
# synchronize on the same PR.
50-
group: blender-smoke-${{ github.event.pull_request.number || github.ref }}${{ github.event.action == 'labeled' && format('-label-{0}', github.event.label.name) || '' }}
59+
# synchronize on the same PR. Pushes to main group per SHA so a later push
60+
# never cancels the run an earlier push's release gate is waiting on.
61+
group: blender-smoke-${{ github.event.pull_request.number || (github.event_name == 'push' && github.sha) || github.ref }}${{ github.event.action == 'labeled' && format('-label-{0}', github.event.label.name) || '' }}
5162
cancel-in-progress: true
5263

5364
jobs:

‎.github/workflows/release.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ jobs:
1616
gate:
1717
name: Gate on CI evidence
1818
# A push to main must not publish unless Validate passed on this exact SHA and,
19-
# when it was merged from a PR, every check on that PR passed (Blender Smoke
20-
# runs on PRs only). Direct pushes have no PR; Validate alone gates them.
19+
# when it was merged from a PR, every check on that PR passed. A direct push
20+
# also waits for its own Blender Smoke push run, unless it changed only
21+
# smoke-ignored paths (see release-gate.sh).
2122
runs-on: ubuntu-latest
2223
if: "!contains(github.event.head_commit.message, '[skip ci]')"
2324
permissions:
@@ -33,6 +34,7 @@ jobs:
3334
env:
3435
GH_TOKEN: ${{ github.token }}
3536
SHA: ${{ github.sha }}
37+
BEFORE: ${{ github.event.before }}
3638
run: bash .github/scripts/release-gate.sh
3739

3840
version-and-release:

‎tests/test_release_gate.py‎

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,20 @@
1818
BASH = shutil.which("bash")
1919

2020
# Fake gh: behaviour comes from env vars so each test sets one scenario.
21-
# FAKE_RUN "<status> <conclusion>" for `gh run list`
21+
# FAKE_RUN "<status> <conclusion>" for `gh run list --workflow validate.yml`
22+
# FAKE_SMOKE the same for `--workflow blender-smoke.yml`
2223
# FAKE_PR PR number for `gh api .../pulls` (empty = direct push)
24+
# FAKE_FILES changed files for `gh api .../commits/SHA` or `.../compare/...`
2325
# FAKE_CHECKS stdout for `gh pr checks`; FAKE_CHECKS_RC its exit code
2426
FAKE_GH = r"""#!/usr/bin/env bash
2527
case "$1 $2" in
26-
"run list") echo "${FAKE_RUN:-completed success}" ;;
27-
"api "*) echo "${FAKE_PR:-}" ;;
28+
"run list")
29+
case " $* " in
30+
*" blender-smoke.yml "*) echo "${FAKE_SMOKE:-completed success}" ;;
31+
*) echo "${FAKE_RUN:-completed success}" ;;
32+
esac ;;
33+
"api "*/pulls) echo "${FAKE_PR:-}" ;;
34+
"api "*) printf '%b\n' "${FAKE_FILES:-examples/a/a.py}" ;;
2835
"pr checks") printf '%s' "${FAKE_CHECKS:-}"; exit "${FAKE_CHECKS_RC:-0}" ;;
2936
*) echo "unexpected gh $*" >&2; exit 99 ;;
3037
esac
@@ -57,8 +64,27 @@ def gate(self, **env):
5764
)
5865
return proc.returncode, proc.stdout + proc.stderr
5966

60-
def test_direct_push_with_green_validate_passes(self):
61-
self.assertEqual(self.gate(FAKE_PR="")[0], 0)
67+
def test_direct_push_with_green_validate_and_smoke_passes(self):
68+
code, out = self.gate(FAKE_PR="")
69+
self.assertEqual(code, 0, out)
70+
self.assertIn("Blender Smoke: success", out)
71+
72+
def test_direct_push_with_red_smoke_blocks(self):
73+
code, out = self.gate(FAKE_PR="", FAKE_SMOKE="completed failure")
74+
self.assertEqual(code, 1)
75+
self.assertIn("Blender Smoke concluded 'failure'", out)
76+
77+
def test_direct_push_with_no_smoke_run_times_out(self):
78+
code, out = self.gate(FAKE_PR="", FAKE_SMOKE="none none", SMOKE_TIMEOUT="0")
79+
self.assertEqual(code, 1)
80+
self.assertIn("Blender Smoke did not finish", out)
81+
82+
def test_docs_only_direct_push_skips_smoke(self):
83+
code, out = self.gate(
84+
FAKE_PR="", FAKE_SMOKE="none none", FAKE_FILES=r"README.md\ndocs/gallery/x.html"
85+
)
86+
self.assertEqual(code, 0, out)
87+
self.assertIn("only smoke-ignored paths", out)
6288

6389
def test_red_validate_blocks(self):
6490
code, out = self.gate(FAKE_RUN="completed failure")

0 commit comments

Comments
 (0)