Skip to content

Commit 5177e24

Browse files
TMHSDigitalclaude
andauthored
fix(ci): ship plugin content whenever it changes, and only when it changes (#430)
Plugin users got updates backwards. A `docs:` fix to a skill never cut a release, so it never reached plugin-dist; meanwhile every gallery or site `fix:` release rebuilt plugin-dist with identical content and a new version, prompting every user to update for nothing (v0.143.2 was site-only; this session alone cut a dozen such releases). - release.yml cuts a patch release when skills/, claude/, rules/, snippets/ or templates/ changed since the last tag, even with no feat/fix subject (.github/scripts/plugin-content-changed.sh, tested). - build_plugin_dist.py --fingerprint hashes a built tree with manifest versions blanked and line endings normalized. The publish step compares the new build with the published plugin-dist and skips the force-push when they match, so the plugin version only moves with its content. Checked against the live branch: today's build matches v0.143.13 exactly, so a site-only release now would not republish. Closes #350 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3bc4949 commit 5177e24

6 files changed

Lines changed: 142 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/usr/bin/env bash
2+
# Exit 0 when plugin content changed between BASE and HEAD, 1 when it did not.
3+
#
4+
# bash .github/scripts/plugin-content-changed.sh v0.143.9
5+
#
6+
# Plugin content is what scripts/build_plugin_dist.py ships: skills/, claude/,
7+
# rules/, snippets/, templates/. release.yml cuts a patch release when this
8+
# says yes even if no commit subject is feat:/fix:, so a `docs:` correction to
9+
# a skill still reaches plugin users (#350).
10+
set -euo pipefail
11+
base="${1:?usage: plugin-content-changed.sh BASE}"
12+
if git diff --quiet "$base" HEAD -- skills claude rules snippets templates; then
13+
exit 1
14+
fi
15+
exit 0

‎.github/workflows/release.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ jobs:
8181
release="true"
8282
elif [ "$kind" != "none" ]; then
8383
bump="$kind"; release="true"
84+
elif bash .github/scripts/plugin-content-changed.sh "$last_tag"; then
85+
# No feat/fix subject, but skills/rules/snippets/templates/claude
86+
# changed: ship a patch so a docs: correction still reaches plugin
87+
# users instead of waiting for the next feat/fix (#350).
88+
echo "Plugin content changed since $last_tag with no feat/fix subject; releasing a patch"
89+
bump="patch"; release="true"
8490
fi
8591
8692
echo "bump=$bump" >> "$GITHUB_OUTPUT"
@@ -262,6 +268,23 @@ jobs:
262268
set -euo pipefail
263269
dist="$RUNNER_TEMP/plugin-dist"
264270
python3 scripts/build_plugin_dist.py --out "$dist"
271+
# Republish only when what plugin users load changed. A release cut for
272+
# the gallery or site would otherwise bump the plugin version with
273+
# identical content and prompt every user to update (#350). The
274+
# fingerprint ignores the manifest "version" values.
275+
new_fp=$(python3 scripts/build_plugin_dist.py --fingerprint "$dist")
276+
old_fp=""
277+
if git fetch -q --depth 1 origin plugin-dist; then
278+
published="$RUNNER_TEMP/plugin-dist-published"
279+
mkdir -p "$published"
280+
git archive FETCH_HEAD | tar -x -C "$published"
281+
old_fp=$(python3 scripts/build_plugin_dist.py --fingerprint "$published")
282+
fi
283+
echo "plugin-dist fingerprint: built=$new_fp published=${old_fp:-<none>}"
284+
if [ "$new_fp" = "$old_fp" ]; then
285+
echo "Plugin content unchanged; plugin-dist stays at its published version."
286+
exit 0
287+
fi
265288
cd "$dist"
266289
git init -q -b plugin-dist
267290
git config user.name "github-actions[bot]"

‎.github/workflows/validate.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,9 @@ jobs:
423423
- name: Plugin-dist --out refuses destructive targets
424424
run: python3 tests/test_build_plugin_dist.py -v
425425

426+
- name: Plugin-content change detector (release cadence)
427+
run: python3 tests/test_plugin_content_changed.py -v
428+
426429
- name: Exit-code gate resolves fail() helpers and tuple returns
427430
run: python3 tests/test_exit_code_readme.py -v
428431

‎AGENTS.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ Blender-Developer-Tools/
6464
- Other types (`chore:`, `docs:`, `ci:`, `refactor:`, etc.) do **not** cut a release: the
6565
workflow runs, decides there is nothing to release, and exits without a tag or version
6666
bump. A mixed push still releases if any commit in range is a `feat:`/`fix:`.
67+
- Exception: if plugin content (`skills/`, `claude/`, `rules/`, `snippets/`, `templates/`)
68+
changed since the last tag, a patch release is cut whatever the commit types, so a
69+
`docs:` fix to a skill still reaches plugin users
70+
(`.github/scripts/plugin-content-changed.sh`).
71+
- The `plugin-dist` branch is republished only when the built plugin differs from the
72+
published one (`build_plugin_dist.py --fingerprint`, which ignores version strings). A
73+
release cut for the gallery or site leaves plugin users on their current version
74+
instead of offering an update with identical content.
6775
- `[skip ci]` in the head commit still bypasses the workflow entirely. With the commit-type
6876
gate above it is now an optional override, not a requirement for non-release commits.
6977
- Commit messages should describe the why, not the what, and carry a DCO

‎scripts/build_plugin_dist.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
python scripts/build_plugin_dist.py --out DIR # build (DIR must be absent or empty)
1212
python scripts/build_plugin_dist.py --out DIR --force # replace a previous build in DIR
1313
python scripts/build_plugin_dist.py --check # build to a temp dir, verify, report size
14+
python scripts/build_plugin_dist.py --fingerprint DIR # content hash, versions ignored
1415
1516
release.yml force-pushes the built tree as a single orphan commit to
1617
`plugin-dist` on every release, so the branch never accumulates history.
@@ -19,7 +20,9 @@
1920
from __future__ import annotations
2021

2122
import argparse
23+
import hashlib
2224
import json
25+
import re
2326
import shutil
2427
import sys
2528
import tempfile
@@ -52,6 +55,24 @@
5255
"""
5356

5457

58+
# The release rewrites only "version" values in the manifests, so a build whose
59+
# fingerprint matches the published branch carries no new plugin content.
60+
_VERSION = re.compile(rb'("version"\s*:\s*")[^"]*(")')
61+
62+
63+
def fingerprint(tree: Path) -> str:
64+
"""sha256 over every file's path and bytes, with manifest versions blanked
65+
and line endings normalized. Equal fingerprints mean a release would
66+
publish nothing new to plugin users (#350)."""
67+
h = hashlib.sha256()
68+
for p in sorted(q for q in tree.rglob("*") if q.is_file() and ".git" not in q.parts):
69+
data = p.read_bytes().replace(b"\r\n", b"\n")
70+
if p.suffix == ".json":
71+
data = _VERSION.sub(rb"\1\2", data)
72+
h.update(p.relative_to(tree).as_posix().encode() + b"\0" + data + b"\0")
73+
return h.hexdigest()
74+
75+
5576
def unsafe_out(out: Path, force: bool) -> str | None:
5677
"""Why `out` must not be deleted, or None when building there is safe.
5778
@@ -114,7 +135,12 @@ def main(argv=None) -> int:
114135
ap.add_argument("--check", action="store_true")
115136
ap.add_argument("--force", action="store_true",
116137
help="replace a non-empty --out directory")
138+
ap.add_argument("--fingerprint", type=Path, metavar="DIR",
139+
help="print the content fingerprint of a built tree and exit")
117140
a = ap.parse_args(argv)
141+
if a.fingerprint:
142+
print(fingerprint(a.fingerprint))
143+
return 0
118144
if not a.out and not a.check:
119145
ap.error("give --out DIR or --check")
120146
if a.out:
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Tests for .github/scripts/plugin-content-changed.sh (#350). Needs bash + git.
2+
3+
Run: python tests/test_plugin_content_changed.py -v
4+
"""
5+
from __future__ import annotations
6+
7+
import shutil
8+
import subprocess
9+
import tempfile
10+
import unittest
11+
from pathlib import Path
12+
13+
SCRIPT = Path(__file__).resolve().parent.parent / ".github" / "scripts" / "plugin-content-changed.sh"
14+
BASH = shutil.which("bash")
15+
16+
17+
def git(cwd, *args):
18+
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
19+
20+
21+
@unittest.skipIf(BASH is None or shutil.which("git") is None, "bash/git not available")
22+
class PluginContentChanged(unittest.TestCase):
23+
def setUp(self):
24+
self.tmp = tempfile.TemporaryDirectory()
25+
self.repo = Path(self.tmp.name)
26+
git(self.repo, "init", "-q")
27+
git(self.repo, "config", "user.email", "t@example.com")
28+
git(self.repo, "config", "user.name", "t")
29+
(self.repo / "skills" / "a").mkdir(parents=True)
30+
(self.repo / "skills" / "a" / "SKILL.md").write_text("one\n", encoding="utf-8")
31+
(self.repo / "README.md").write_text("readme\n", encoding="utf-8")
32+
git(self.repo, "add", "-A")
33+
git(self.repo, "commit", "-q", "-m", "base")
34+
git(self.repo, "tag", "v1")
35+
36+
def tearDown(self):
37+
self.tmp.cleanup()
38+
39+
def changed(self):
40+
return subprocess.run([BASH, SCRIPT.as_posix(), "v1"], cwd=self.repo).returncode == 0
41+
42+
def commit(self, rel, text):
43+
path = self.repo / rel
44+
path.parent.mkdir(parents=True, exist_ok=True)
45+
path.write_text(text, encoding="utf-8")
46+
git(self.repo, "add", "-A")
47+
git(self.repo, "commit", "-q", "-m", "docs: change")
48+
49+
def test_no_change(self):
50+
self.assertFalse(self.changed())
51+
52+
def test_docs_outside_plugin_is_not_content(self):
53+
self.commit("README.md", "new readme\n")
54+
self.commit("docs/gallery/index.html", "<p>x</p>\n")
55+
self.assertFalse(self.changed())
56+
57+
def test_skill_edit_is_content(self):
58+
self.commit("skills/a/SKILL.md", "two\n")
59+
self.assertTrue(self.changed())
60+
61+
def test_rule_and_snippet_dirs_count(self):
62+
self.commit("snippets/x.py", "print(1)\n")
63+
self.assertTrue(self.changed())
64+
65+
66+
if __name__ == "__main__":
67+
unittest.main()

0 commit comments

Comments
 (0)