From 11164d7866128c776395364368a3bd4f82db63d1 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 13 Sep 2026 19:58:12 -0400 Subject: [PATCH] fix(notifications): add the deprecation block its own status requires, and gate it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit free/notifications declares "status": "deprecated" but carried only the flat deprecated / deprecatedSince / deprecated_in / removal_target / replacedBy fields. cli/internal/plugin/manifest.go requires a `deprecation` OBJECT once status is "deprecated", so it refused to parse this manifest, and listInstalled() skipped the directory. The user-visible effect: `nself plugin install notifications` prints "installed successfully", writes the directory and manifest, and the plugin is then absent from `nself plugin list --installed` AND from `nself doctor`, with no explanation anywhere. It is one of the eight plugins in the free Task Bundle, so the flagship free bundle installed eight and listed seven. It is the only plugin in free/ in this state — swept the whole tree. Values are derived, not invented: announcedDate 2026-05-07 — the real announcement, commit 6315c5f "feat(plugins/notifications): deprecate in favor of notify (S8.T29)". eolDate 2026-11-07 — exactly the 6-month minimum that .github/wiki/Deprecation-Policy.md sets for free (MIT) plugins. replacedBy notify — already asserted by the flat fields. migrationGuide https://github.com/nself-org/plugins/wiki/Notify — verified HTTP 200. The obvious nself.org/docs/plugins/notify 404s, and nself.org is not currently redeploying, so pointing at it would have satisfied the schema while failing the policy's actual requirement. The flat fields are left in place: other tooling may read them, and they are not in conflict with the block. Gate added in the same change, because the existing "Validate plugin.json files" step checks required fields and never looked at this. Any plugin with status "deprecated" must now carry announcedDate, eolDate and migrationGuide; eolDate must be at least 182 days after announcedDate per the policy; and migrationGuide must be an absolute URL. Reachability is checked too — a definitive 4xx fails, while a network error only prints a note, so a DNS blip cannot turn this into a flaky gate. Verified: the fixed manifest parses and lists — ran listInstalled() from the CLI against a temp dir holding this exact file, and "notifications" now appears. Gate proved in all three failing directions, each with its own message and exit 1: block removed (the shipped defect), notice period shortened to 30 days, and migrationGuide pointed at the 404ing nself.org URL. Passes on the committed tree. Workflow YAML parses. --- .github/workflows/validate.yml | 79 ++++++++++++++++++++++++++++++++++ free/notifications/plugin.json | 8 +++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 15c91d36..e1d5420f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -111,6 +111,85 @@ jobs: printf "\nAll plugin.json files are valid\n" + # A plugin declaring status "deprecated" must carry a complete deprecation + # block. cli/internal/plugin/manifest.go REFUSES to parse the manifest + # otherwise, and listInstalled() then skips the directory — so the plugin + # installs "successfully" and is invisible to `nself plugin list + # --installed` and `nself doctor`. + # + # That is not hypothetical: free/notifications shipped status "deprecated" + # with only the flat deprecated/deprecatedSince/replacedBy fields and no + # block, and it is one of the eight plugins in the free Task Bundle. The + # bundle installed eight plugins and listed seven, with nothing saying why + # (found 2026-09-13). The step above validates required fields but never + # looked at the deprecation block, so it passed this all the way through. + # + # Notice period comes from .github/wiki/Deprecation-Policy.md: free (MIT) + # plugins get a minimum of 6 months from announcedDate to eolDate. + - name: Deprecated plugins carry a complete deprecation block + run: | + python3 - <<'PY' + import json, pathlib, sys, datetime, urllib.request, urllib.error + + REQUIRED = ("announcedDate", "eolDate", "migrationGuide") + MIN_NOTICE_DAYS = 182 # 6 months, per Deprecation-Policy.md + errors = [] + + def iso(name, value, where): + try: + return datetime.date.fromisoformat(value) + except (TypeError, ValueError): + errors.append(f"{where}: deprecation.{name} is not an ISO date (YYYY-MM-DD): {value!r}") + return None + + for f in sorted(pathlib.Path("free").glob("*/plugin.json")): + d = json.loads(f.read_text()) + if d.get("status") != "deprecated": + continue + block = d.get("deprecation") + if not isinstance(block, dict): + errors.append( + f"{f}: status is \"deprecated\" but there is no `deprecation` block. " + "The flat deprecated/deprecatedSince/replacedBy fields are NOT a " + "substitute - the CLI will refuse to parse this manifest and the " + "plugin will silently vanish from every listing." + ) + continue + for key in REQUIRED: + if not block.get(key): + errors.append(f"{f}: deprecation.{key} is required") + a = iso("announcedDate", block.get("announcedDate"), f) + e = iso("eolDate", block.get("eolDate"), f) + if a and e: + notice = (e - a).days + if notice < MIN_NOTICE_DAYS: + errors.append( + f"{f}: notice period is {notice} days; Deprecation-Policy.md " + f"requires at least {MIN_NOTICE_DAYS} (6 months) for free plugins" + ) + url = block.get("migrationGuide") or "" + if url and not url.startswith(("http://", "https://")): + errors.append(f"{f}: deprecation.migrationGuide must be an absolute URL, got {url!r}") + elif url: + # The policy requires this to resolve. A definitive 4xx is a real + # defect and fails. A network error is NOT treated as a failure - + # a DNS blip must not turn this into a flaky gate. + try: + req = urllib.request.Request(url, method="GET", headers={"User-Agent": "nself-ci"}) + urllib.request.urlopen(req, timeout=20).read(1) + except urllib.error.HTTPError as ex: + errors.append(f"{f}: deprecation.migrationGuide {url} returned HTTP {ex.code}") + except Exception as ex: + print(f" note: could not reach {url} ({ex}) - not failing on a network error") + + if errors: + print("\nDeprecation block validation failed:") + for e in errors: + print(f" ERROR: {e}") + sys.exit(1) + print("All deprecated plugins carry a complete, policy-compliant deprecation block.") + PY + - name: Check for duplicate ports run: | printf "Checking for port conflicts...\n" diff --git a/free/notifications/plugin.json b/free/notifications/plugin.json index b7bd85e0..56f8ce52 100644 --- a/free/notifications/plugin.json +++ b/free/notifications/plugin.json @@ -92,5 +92,11 @@ ], "min_memory_mb": 64, "systemd_after": "network.target", - "tier": "free" + "tier": "free", + "deprecation": { + "announcedDate": "2026-05-07", + "eolDate": "2026-11-07", + "replacedBy": "notify", + "migrationGuide": "https://github.com/nself-org/plugins/wiki/Notify" + } }