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
15 changes: 15 additions & 0 deletions .github/scripts/plugin-content-changed.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Exit 0 when plugin content changed between BASE and HEAD, 1 when it did not.
#
# bash .github/scripts/plugin-content-changed.sh v0.143.9
#
# Plugin content is what scripts/build_plugin_dist.py ships: skills/, claude/,
# rules/, snippets/, templates/. release.yml cuts a patch release when this
# says yes even if no commit subject is feat:/fix:, so a `docs:` correction to
# a skill still reaches plugin users (#350).
set -euo pipefail
base="${1:?usage: plugin-content-changed.sh BASE}"
if git diff --quiet "$base" HEAD -- skills claude rules snippets templates; then
exit 1
fi
exit 0
23 changes: 23 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ jobs:
release="true"
elif [ "$kind" != "none" ]; then
bump="$kind"; release="true"
elif bash .github/scripts/plugin-content-changed.sh "$last_tag"; then
# No feat/fix subject, but skills/rules/snippets/templates/claude
# changed: ship a patch so a docs: correction still reaches plugin
# users instead of waiting for the next feat/fix (#350).
echo "Plugin content changed since $last_tag with no feat/fix subject; releasing a patch"
bump="patch"; release="true"
fi

echo "bump=$bump" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -262,6 +268,23 @@ jobs:
set -euo pipefail
dist="$RUNNER_TEMP/plugin-dist"
python3 scripts/build_plugin_dist.py --out "$dist"
# Republish only when what plugin users load changed. A release cut for
# the gallery or site would otherwise bump the plugin version with
# identical content and prompt every user to update (#350). The
# fingerprint ignores the manifest "version" values.
new_fp=$(python3 scripts/build_plugin_dist.py --fingerprint "$dist")
old_fp=""
if git fetch -q --depth 1 origin plugin-dist; then
published="$RUNNER_TEMP/plugin-dist-published"
mkdir -p "$published"
git archive FETCH_HEAD | tar -x -C "$published"
old_fp=$(python3 scripts/build_plugin_dist.py --fingerprint "$published")
fi
echo "plugin-dist fingerprint: built=$new_fp published=${old_fp:-<none>}"
if [ "$new_fp" = "$old_fp" ]; then
echo "Plugin content unchanged; plugin-dist stays at its published version."
exit 0
fi
cd "$dist"
git init -q -b plugin-dist
git config user.name "github-actions[bot]"
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,9 @@ jobs:
- name: Plugin-dist --out refuses destructive targets
run: python3 tests/test_build_plugin_dist.py -v

- name: Plugin-content change detector (release cadence)
run: python3 tests/test_plugin_content_changed.py -v

- name: Exit-code gate resolves fail() helpers and tuple returns
run: python3 tests/test_exit_code_readme.py -v

Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ Blender-Developer-Tools/
- Other types (`chore:`, `docs:`, `ci:`, `refactor:`, etc.) do **not** cut a release: the
workflow runs, decides there is nothing to release, and exits without a tag or version
bump. A mixed push still releases if any commit in range is a `feat:`/`fix:`.
- Exception: if plugin content (`skills/`, `claude/`, `rules/`, `snippets/`, `templates/`)
changed since the last tag, a patch release is cut whatever the commit types, so a
`docs:` fix to a skill still reaches plugin users
(`.github/scripts/plugin-content-changed.sh`).
- The `plugin-dist` branch is republished only when the built plugin differs from the
published one (`build_plugin_dist.py --fingerprint`, which ignores version strings). A
release cut for the gallery or site leaves plugin users on their current version
instead of offering an update with identical content.
- `[skip ci]` in the head commit still bypasses the workflow entirely. With the commit-type
gate above it is now an optional override, not a requirement for non-release commits.
- Commit messages should describe the why, not the what, and carry a DCO
Expand Down
26 changes: 26 additions & 0 deletions scripts/build_plugin_dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
python scripts/build_plugin_dist.py --out DIR # build (DIR must be absent or empty)
python scripts/build_plugin_dist.py --out DIR --force # replace a previous build in DIR
python scripts/build_plugin_dist.py --check # build to a temp dir, verify, report size
python scripts/build_plugin_dist.py --fingerprint DIR # content hash, versions ignored

release.yml force-pushes the built tree as a single orphan commit to
`plugin-dist` on every release, so the branch never accumulates history.
Expand All @@ -19,7 +20,9 @@
from __future__ import annotations

import argparse
import hashlib
import json
import re
import shutil
import sys
import tempfile
Expand Down Expand Up @@ -52,6 +55,24 @@
"""


# The release rewrites only "version" values in the manifests, so a build whose
# fingerprint matches the published branch carries no new plugin content.
_VERSION = re.compile(rb'("version"\s*:\s*")[^"]*(")')


def fingerprint(tree: Path) -> str:
"""sha256 over every file's path and bytes, with manifest versions blanked
and line endings normalized. Equal fingerprints mean a release would
publish nothing new to plugin users (#350)."""
h = hashlib.sha256()
for p in sorted(q for q in tree.rglob("*") if q.is_file() and ".git" not in q.parts):
data = p.read_bytes().replace(b"\r\n", b"\n")
if p.suffix == ".json":
data = _VERSION.sub(rb"\1\2", data)
h.update(p.relative_to(tree).as_posix().encode() + b"\0" + data + b"\0")
return h.hexdigest()


def unsafe_out(out: Path, force: bool) -> str | None:
"""Why `out` must not be deleted, or None when building there is safe.

Expand Down Expand Up @@ -114,7 +135,12 @@ def main(argv=None) -> int:
ap.add_argument("--check", action="store_true")
ap.add_argument("--force", action="store_true",
help="replace a non-empty --out directory")
ap.add_argument("--fingerprint", type=Path, metavar="DIR",
help="print the content fingerprint of a built tree and exit")
a = ap.parse_args(argv)
if a.fingerprint:
print(fingerprint(a.fingerprint))
return 0
if not a.out and not a.check:
ap.error("give --out DIR or --check")
if a.out:
Expand Down
67 changes: 67 additions & 0 deletions tests/test_plugin_content_changed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Tests for .github/scripts/plugin-content-changed.sh (#350). Needs bash + git.

Run: python tests/test_plugin_content_changed.py -v
"""
from __future__ import annotations

import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path

SCRIPT = Path(__file__).resolve().parent.parent / ".github" / "scripts" / "plugin-content-changed.sh"
BASH = shutil.which("bash")


def git(cwd, *args):
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)


@unittest.skipIf(BASH is None or shutil.which("git") is None, "bash/git not available")
class PluginContentChanged(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.repo = Path(self.tmp.name)
git(self.repo, "init", "-q")
git(self.repo, "config", "user.email", "t@example.com")
git(self.repo, "config", "user.name", "t")
(self.repo / "skills" / "a").mkdir(parents=True)
(self.repo / "skills" / "a" / "SKILL.md").write_text("one\n", encoding="utf-8")
(self.repo / "README.md").write_text("readme\n", encoding="utf-8")
git(self.repo, "add", "-A")
git(self.repo, "commit", "-q", "-m", "base")
git(self.repo, "tag", "v1")

def tearDown(self):
self.tmp.cleanup()

def changed(self):
return subprocess.run([BASH, SCRIPT.as_posix(), "v1"], cwd=self.repo).returncode == 0

def commit(self, rel, text):
path = self.repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
git(self.repo, "add", "-A")
git(self.repo, "commit", "-q", "-m", "docs: change")

def test_no_change(self):
self.assertFalse(self.changed())

def test_docs_outside_plugin_is_not_content(self):
self.commit("README.md", "new readme\n")
self.commit("docs/gallery/index.html", "<p>x</p>\n")
self.assertFalse(self.changed())

def test_skill_edit_is_content(self):
self.commit("skills/a/SKILL.md", "two\n")
self.assertTrue(self.changed())

def test_rule_and_snippet_dirs_count(self):
self.commit("snippets/x.py", "print(1)\n")
self.assertTrue(self.changed())


if __name__ == "__main__":
unittest.main()
Loading