From 7040243448318dd64160bcfbbe36fc9bc7c79fce Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Fri, 11 Sep 2026 07:30:53 -0400 Subject: [PATCH] fix(counts): re-vendor counts.json and guard it against drift The vendored artifact still carried the old overlap field. It was copied before the canonical rule changed from an invented dualRegistry flag to the real tier_pair one, so `nself plugin count --json` emitted a field no registry has ever used while plugins main emitted the correct one. The printed numbers were right; the schema was not. Re-vendors from plugins main, which also replaces the PENDING-SYNC source placeholders with the real registry blob SHAs, and updates the Go struct to match. Adds Counts Vendor Sync to stop this recurring. A vendored copy is a copy and copies drift; nothing was checking this one. nself-org/plugins is public so the check needs no token. It runs nightly as well as on change, because the canonical artifact lives in another repo and a push here is not the trigger that matters. --- .github/workflows/counts-vendor-sync.yml | 83 ++++++++++++++++++++++++ internal/plugin/count/count.go | 18 +++-- internal/plugin/count/counts.json | 49 +++++++++++--- 3 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/counts-vendor-sync.yml diff --git a/.github/workflows/counts-vendor-sync.yml b/.github/workflows/counts-vendor-sync.yml new file mode 100644 index 00000000..98ffafdb --- /dev/null +++ b/.github/workflows/counts-vendor-sync.yml @@ -0,0 +1,83 @@ +name: Counts Vendor Sync + +# The CLI embeds a vendored copy of plugins/counts.json so `nself plugin count` +# works offline with no checkouts. A vendored copy is a copy, and a copy drifts: +# on 2026-09-11 the overlap rule changed in the canonical artifact and the +# vendored copy kept the old field name for hours, which is exactly the class of +# drift this whole pipeline exists to remove. Nothing was checking it. +# +# nself-org/plugins is PUBLIC, so this needs no token. +# +# This compares the vendored copy against the canonical artifact on plugins main, +# ignoring only generated_at (a wall-clock stamp, expected to differ). + +on: + push: + branches: [main] + paths: + - "internal/plugin/count/counts.json" + - ".github/workflows/counts-vendor-sync.yml" + pull_request: + paths: + - "internal/plugin/count/counts.json" + - ".github/workflows/counts-vendor-sync.yml" + schedule: + # Nightly: the canonical artifact changes in another repo, so a push here is + # not the trigger that matters. This is what actually catches upstream drift. + - cron: "30 6 * * *" + workflow_dispatch: + +concurrency: + group: counts-vendor-sync-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify-vendored-counts: + name: Vendored counts.json matches plugins main + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout cli + uses: actions/checkout@v4 + + - name: Checkout plugins (public, no token required) + uses: actions/checkout@v4 + with: + repository: nself-org/plugins + path: .plugins-canonical + ref: main + + - name: Compare vendored copy against the canonical artifact + run: | + python3 - <<'PY' + import json, sys + + vendored = json.load(open("internal/plugin/count/counts.json")) + canonical = json.load(open(".plugins-canonical/counts.json")) + + # generated_at is a wall-clock timestamp and is expected to differ. + vendored.pop("generated_at", None) + canonical.pop("generated_at", None) + + if vendored == canonical: + print(f"vendored counts.json matches plugins main (advertised: {canonical['advertised']})") + sys.exit(0) + + print("MISMATCH: the vendored counts.json has drifted from plugins main.", file=sys.stderr) + print("Re-vendor it with:", file=sys.stderr) + print(" git -C ../plugins show origin/main:counts.json > internal/plugin/count/counts.json", file=sys.stderr) + print("", file=sys.stderr) + + only_v = set(vendored) - set(canonical) + only_c = set(canonical) - set(vendored) + if only_v: + print(f"keys only in vendored: {sorted(only_v)}", file=sys.stderr) + if only_c: + print(f"keys only in canonical: {sorted(only_c)}", file=sys.stderr) + for key in sorted(set(vendored) & set(canonical)): + if vendored[key] != canonical[key]: + print(f"differs at {key}:", file=sys.stderr) + print(f" vendored: {json.dumps(vendored[key])}", file=sys.stderr) + print(f" canonical: {json.dumps(canonical[key])}", file=sys.stderr) + sys.exit(1) + PY diff --git a/internal/plugin/count/count.go b/internal/plugin/count/count.go index e51c8674..3fdb5ec8 100644 --- a/internal/plugin/count/count.go +++ b/internal/plugin/count/count.go @@ -72,13 +72,19 @@ type RegistryCount struct { // Overlap describes slugs present in both registries. // -// sharedSlugs is every slug in both. dualRegistry is the subset explicitly -// marked as two genuinely different plugins (counted twice); duplicates is -// the remainder (counted once, subtracted from the naive sum). +// sharedSlugs is every slug in both. A shared slug is one two-tier product: +// the free entry is its free tier, the pro entry its pro tier. Both registry +// entries carry tier_pair:true, and the product counts once, so tierPairs and +// duplicates hold the same slugs. Both are emitted because duplicates says +// what was subtracted from the naive sum and tierPairs says why. +// +// There is no counted-twice case. An earlier draft of this model had one, +// keyed on a dualRegistry flag that no registry entry has ever carried, so the +// field was always empty and the real rule went unread. type Overlap struct { - SharedSlugs []string `json:"sharedSlugs"` - DualRegistry []string `json:"dualRegistry"` - Duplicates []string `json:"duplicates"` + SharedSlugs []string `json:"sharedSlugs"` + TierPairs []string `json:"tierPairs"` + Duplicates []string `json:"duplicates"` } // Totals is the combined free+pro view before and after de-duplication. diff --git a/internal/plugin/count/counts.json b/internal/plugin/count/counts.json index 652f1554..bbfe3ada 100644 --- a/internal/plugin/count/counts.json +++ b/internal/plugin/count/counts.json @@ -1,14 +1,47 @@ { - "_generated": "GENERATED BY plugins/scripts/plugin-counts.sh — DO NOT HAND EDIT", + "_generated": "GENERATED BY plugins/scripts/plugin-counts.sh \u2014 DO NOT HAND EDIT", "schema_version": 1, - "generated_at": "2026-09-11T00:00:00Z", + "generated_at": "2026-09-11T10:29:19Z", "sources": { - "free": { "repo": "nself-org/plugins", "sha": "PENDING-SYNC" }, - "pro": { "repo": "nself-org/plugins-pro", "sha": "PENDING-SYNC" } + "free": { + "repo": "nself-org/plugins", + "sha": "d5e472bc3462013e44263f4c426f2e79fee69bae" + }, + "pro": { + "repo": "nself-org/plugins-pro", + "sha": "01624292e61f115ca5da3ab57a60f61129859ba2" + } + }, + "free": { + "entries": 129, + "installable": 127, + "nonInstallable": [ + "family-gedcom", + "shared-utils" + ] + }, + "pro": { + "entries": 46, + "installable": 46, + "nonInstallable": [] + }, + "overlap": { + "sharedSlugs": [ + "cron", + "notify" + ], + "tierPairs": [ + "cron", + "notify" + ], + "duplicates": [ + "cron", + "notify" + ] + }, + "totals": { + "entries": 173, + "installable": 171 }, - "free": { "entries": 129, "installable": 127, "nonInstallable": ["family-gedcom", "shared-utils"] }, - "pro": { "entries": 46, "installable": 46, "nonInstallable": [] }, - "overlap": { "sharedSlugs": ["cron", "notify"], "dualRegistry": [], "duplicates": ["cron", "notify"] }, - "totals": { "entries": 173, "installable": 171 }, "advertised": 171 }