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 }