diff --git a/.github/scripts/plugin-content-changed.sh b/.github/scripts/plugin-content-changed.sh new file mode 100755 index 00000000..80758ec1 --- /dev/null +++ b/.github/scripts/plugin-content-changed.sh @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81963165..0142064b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" @@ -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:-}" + 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]" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 01817e33..36409114 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 52c11de7..cb1f631c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/scripts/build_plugin_dist.py b/scripts/build_plugin_dist.py index c291b439..ee49e510 100644 --- a/scripts/build_plugin_dist.py +++ b/scripts/build_plugin_dist.py @@ -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. @@ -19,7 +20,9 @@ from __future__ import annotations import argparse +import hashlib import json +import re import shutil import sys import tempfile @@ -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. @@ -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: diff --git a/tests/test_plugin_content_changed.py b/tests/test_plugin_content_changed.py new file mode 100644 index 00000000..3aa45128 --- /dev/null +++ b/tests/test_plugin_content_changed.py @@ -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", "

x

\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()