diff --git a/catalog.json b/catalog.json index 4f05e6574..1f8299fee 100644 --- a/catalog.json +++ b/catalog.json @@ -95,7 +95,7 @@ "_generated": "GENERATED BY tools/catalog — DO NOT HAND EDIT", "advertised": 168, "free": { - "entries": 131, + "entries": 130, "installable": 124, "nonInstallable": [ "family-ancestry", @@ -103,8 +103,7 @@ "family-gedcom", "family-myheritage", "family-wikitree", - "shared-utils", - "transactional-email" + "shared-utils" ] }, "generated_from": { @@ -133,7 +132,7 @@ }, "schema_version": 1, "totals": { - "entries": 175, + "entries": 174, "installable": 168 } }, @@ -5347,31 +5346,6 @@ "tier_pair": false, "version": "1.2.13" }, - { - "bundles": [], - "category": "communication", - "checksum": "c855f419baf8346687bc876bdd766a715adb141b8b55a096016247eb9176411b", - "commands": null, - "description": "Provider-agnostic transactional email: template rendering, per-tenant domain management, SPF/DKIM reporting, delivery webhook relay.", - "docs_url": null, - "installable": false, - "maturity": "planned", - "release_signature": { - "alg": "ed25519", - "key_id": "plugins-2ebca8dfad98e19e", - "sig": "KDunM6V7oR5WWdxCGKhI5LRaGipqdPkH0JfEb8aZcwRArtujni/qHso0/4mNyFml5egj3IndMJu8K4S7EFdTCw==" - }, - "requires": { - "nself": ">=1.1.0" - }, - "schema": "np_transactional_email", - "service_kind": "library", - "slug": "transactional-email", - "tarball_url": "https://github.com/nself-org/plugins/releases/download/v1.2.13/transactional-email-1.2.13.tar.gz", - "tier": "free", - "tier_pair": false, - "version": "1.2.13" - }, { "bundles": [ "claw" diff --git a/counts.json b/counts.json index f6960b84e..1c8fcb1a1 100644 --- a/counts.json +++ b/counts.json @@ -2,7 +2,7 @@ "_generated": "GENERATED BY tools/catalog — DO NOT HAND EDIT", "advertised": 168, "free": { - "entries": 131, + "entries": 130, "installable": 124, "nonInstallable": [ "family-ancestry", @@ -10,8 +10,7 @@ "family-gedcom", "family-myheritage", "family-wikitree", - "shared-utils", - "transactional-email" + "shared-utils" ] }, "generated_from": { @@ -40,7 +39,7 @@ }, "schema_version": 1, "totals": { - "entries": 175, + "entries": 174, "installable": 168 } } diff --git a/registry.json b/registry.json index 1ff3d9cbb..0666903f7 100644 --- a/registry.json +++ b/registry.json @@ -7579,60 +7579,6 @@ "tier": "free", "version": "1.2.13" }, - "transactional-email": { - "author": "nself", - "catalog": { - "maturity": "planned", - "requires": { - "nself": ">=1.1.0" - }, - "schema": "np_transactional_email", - "service_kind": "library" - }, - "category": "communication", - "checksum": "c855f419baf8346687bc876bdd766a715adb141b8b55a096016247eb9176411b", - "checksums": { - "sha256": "c855f419baf8346687bc876bdd766a715adb141b8b55a096016247eb9176411b" - }, - "dependencies": [], - "description": "Provider-agnostic transactional email: template rendering, per-tenant domain management, SPF/DKIM reporting, delivery webhook relay.", - "download_url": "https://plugins.nself.org/plugins/transactional-email/tarball", - "implementation": { - "entryPoint": "binary", - "language": "go" - }, - "installable": false, - "language": "go", - "license": "MIT", - "minNselfVersion": "1.1.0", - "min_nself_version": "1.1.0", - "name": "transactional-email", - "releaseTag": "v1.2.13", - "release_signature": { - "alg": "ed25519", - "key_id": "plugins-2ebca8dfad98e19e", - "sig": "KDunM6V7oR5WWdxCGKhI5LRaGipqdPkH0JfEb8aZcwRArtujni/qHso0/4mNyFml5egj3IndMJu8K4S7EFdTCw==" - }, - "requires_license": false, - "tables": [ - "np_temail_domains", - "np_temail_messages", - "np_temail_templates" - ], - "tags": [ - "email", - "transactional", - "smtp", - "elastic-email", - "postmark", - "templates", - "webhooks" - ], - "tarball": "https://github.com/nself-org/plugins/releases/download/v1.2.13/transactional-email-1.2.13.tar.gz", - "tarballUrl": "https://github.com/nself-org/plugins/releases/download/v1.2.13/transactional-email-1.2.13.tar.gz", - "tier": "free", - "version": "1.2.13" - }, "vpn": { "author": "nself", "catalog": { @@ -8066,7 +8012,7 @@ "version": "1.2.13" } }, - "plugins_count": 131, + "plugins_count": 130, "schema_version": "2.0.0", "tier": "free" } diff --git a/releases.json b/releases.json index e4f9a820f..04d7aee08 100644 --- a/releases.json +++ b/releases.json @@ -2588,17 +2588,6 @@ "tarball_url": "https://github.com/nself-org/plugins/releases/download/v1.2.13/traefik-1.2.13.tar.gz", "version": "1.2.13" }, - "transactional-email": { - "release_signature": { - "alg": "ed25519", - "key_id": "plugins-2ebca8dfad98e19e", - "sig": "KDunM6V7oR5WWdxCGKhI5LRaGipqdPkH0JfEb8aZcwRArtujni/qHso0/4mNyFml5egj3IndMJu8K4S7EFdTCw==" - }, - "release_tag": "v1.2.13", - "sha256": "c855f419baf8346687bc876bdd766a715adb141b8b55a096016247eb9176411b", - "tarball_url": "https://github.com/nself-org/plugins/releases/download/v1.2.13/transactional-email-1.2.13.tar.gz", - "version": "1.2.13" - }, "vpn": { "release_signature": { "alg": "ed25519", diff --git a/scripts/ci/tests/test_registry_empty_plugins.py b/scripts/ci/tests/test_registry_empty_plugins.py new file mode 100644 index 000000000..242bbd2ef --- /dev/null +++ b/scripts/ci/tests/test_registry_empty_plugins.py @@ -0,0 +1,103 @@ +"""Gate: the registry never serves a plugin that has no implementation (D-1458). + +A registry entry with a tarball URL is installable bytes, signed as if shipped. A plugin whose free/ +directory holds only its manifest and docs (plugin.json, README.md, SPEC.md ...) has no handlers, migrations, +compose fragment or source: `nself add ` would install a plugin that does nothing. Rules: + + * a registry entry that carries a tarball for an empty plugin fails, unless it is listed in EMPTY_SERVED; + * a listed slug that gained an implementation, or left the registry, fails until its line is deleted, so + the list only shrinks (same ratchet shape as test_plugin_nginx_server_ratchet.py). + +EMPTY_SERVED is debt, not approval. The fix for a slug is to drop its row from releases.json and regenerate +(scripts/plugin-counts.sh), as was done for transactional-email. + +Run: python3 -m unittest discover -s scripts/ci/tests -p 'test_registry_empty_plugins.py' +No docker, network or third-party packages. +""" +import json +import os +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + +# Files that describe a plugin without implementing it. +DOC_FILES = {"plugin.json", "README.md", "SPEC.md", "LICENSE", "CHANGELOG.md"} + +# Still served as installable bytes with nothing behind them. Remove a line when the plugin is implemented +# or its release row is dropped. +EMPTY_SERVED = { + "family-familysearch", + "family-myheritage", + "family-wikitree", + "nself-geo", + "nself-image", + "nself-pdf", + "nself-scan", +} + + +def is_empty(slug): + """True when free/ is missing or holds nothing but manifest and docs.""" + base = os.path.join(ROOT, "free", slug) + if not os.path.isdir(base): + return True + for _, _, files in os.walk(base): + if any(f not in DOC_FILES for f in files): + return False + return True + + +def served_empty(registry): + """Slugs whose registry entry carries a tarball while the plugin directory is empty.""" + plugins = registry["plugins"] + return sorted( + slug for slug, entry in plugins.items() + if (entry.get("tarball") or entry.get("download_url")) and is_empty(slug) + ) + + +class RegistryEmptyPlugins(unittest.TestCase): + def setUp(self): + with open(os.path.join(ROOT, "registry.json")) as f: + self.registry = json.load(f) + + def test_no_new_empty_plugin_is_served(self): + new = [s for s in served_empty(self.registry) if s not in EMPTY_SERVED] + self.assertEqual( + new, [], + "registry.json serves a tarball for a plugin with no implementation; drop its row from " + "releases.json and run scripts/plugin-counts.sh: %s" % ", ".join(new)) + + def test_empty_served_list_only_shrinks(self): + still = set(served_empty(self.registry)) + stale = sorted(EMPTY_SERVED - still) + self.assertEqual( + stale, [], + "delete these from EMPTY_SERVED, they are implemented or no longer served: %s" % ", ".join(stale)) + + def test_detector_flags_a_docs_only_directory(self): + # The gate must be able to fail: a docs-only plugin dir is empty, one with a source file is not. + import tempfile + global ROOT + saved = ROOT + try: + with tempfile.TemporaryDirectory() as tmp: + ROOT = tmp + os.makedirs(os.path.join(tmp, "free", "docs-only")) + os.makedirs(os.path.join(tmp, "free", "real", "migrations")) + for name in ("plugin.json", "README.md", "SPEC.md"): + open(os.path.join(tmp, "free", "docs-only", name), "w").close() + open(os.path.join(tmp, "free", "real", "plugin.json"), "w").close() + open(os.path.join(tmp, "free", "real", "migrations", "001_init.up.sql"), "w").close() + reg = {"plugins": { + "docs-only": {"tarball": "https://example.invalid/a.tar.gz"}, + "real": {"tarball": "https://example.invalid/b.tar.gz"}, + "hidden": {}, + }} + self.assertEqual(served_empty(reg), ["docs-only"]) + finally: + ROOT = saved + + +if __name__ == "__main__": + unittest.main()