Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/counts-vendor-sync.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 12 additions & 6 deletions internal/plugin/count/count.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 41 additions & 8 deletions internal/plugin/count/counts.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading