Skip to content

Commit 2b17cc4

Browse files
feat(registry): move Skills when GitHub moves their Repository (#499)
* feat(registry): move Repositories that GitHub renamed or transferred ADR-0002 kept a moved Repository under its old name, so pages, sitemaps and run commands named Repositories GitHub no longer serves, and two renames left duplicate Repositories in the registry. Sync now follows GitHub: it stores the Repository ID, moves every row to the new name in one batch, and keeps the old name as an alias that answers 301. ADR-0013 replaces ADR-0002. * chore(scripts): add the moved Repository backfill Sync moves a Repository the next time it reads it. The script runs the same statements now for the 13 moves the 2026-10-07 run sweep found, with a read-only dry run as the default. * fix(registry): renumber the moves migration and parse the ID once Main gained its own 0144 migration, and two migrations may not share a number. The batched summary read narrowed the Repository ID with a check TypeScript could not carry into the parser, so the parser now owns it. * fix(registry): queue moved Repositories past the sync prefetch The hourly prefetch records an unchanged Repository in place without a sync job. A moved Repository with an unchanged tree never reached syncRepo, so its rows would never move. * fix(registry): refuse a move into a name another Repository holds * fix(scripts): refuse a backfill merge into another Repository's row The backfill now runs the held-row check sync runs, so it never merges into a row stored for a different Repository ID. It also stops with a clear message when the D1 lacks the moves migration. * chore: renumber the repository move migration and ADR #510 adds migration 0145 and ADR-0013, and #512 adds ADR-0014. This branch moves to 0146 and ADR-0015 so all three merge without a clash. * fix(scripts): reserve migration 0145 for pull request 510 --------- Co-authored-by: Harlan GitHub Agent <harlan-github-agent[bot]@users.noreply.github.com>
1 parent 62d0f07 commit 2b17cc4

32 files changed

Lines changed: 1561 additions & 107 deletions

‎VISION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ The filter for skilld-scoped work, precise enough to hold a proposal next to it
9595

9696
### 1. Provenance is the quality signal
9797

98-
A Skill is trustworthy because you can see who wrote it and read it before you run it. Every surface that shows a Skill names the author. The Skill page links the exact source file in its repository; other surfaces link the Skill page, which is where a visitor inspects a Skill. Registry identity is frozen at admission (ADR-0002) so provenance URLs never rot. Machine-generated metadata is labeled as such and never dressed as authorship.
98+
A Skill is trustworthy because you can see who wrote it and read it before you run it. Every surface that shows a Skill names the author. The Skill page links the exact source file in its repository; other surfaces link the Skill page, which is where a visitor inspects a Skill. When GitHub moves a Repository, its Skills move to the new name and every old URL answers 301 (ADR-0015), so provenance URLs never rot. Machine-generated metadata is labeled as such and never dressed as authorship.
9999

100100
**How to apply:** no Skill card, search result, or digest entry ships without its author and a link to its Skill page or its source. A layout that drops provenance to save space fails review. Trust signals (official repository, owner-verified) are earned facts, never editorial favors.
101101

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# ADR-0015: Follow moved Repositories
2+
3+
**Status:** Accepted
4+
**Date:** 2026-10-07
5+
6+
Replaces [ADR-0002](0002-freeze-registry-repository-identity.md).
7+
8+
## Context
9+
10+
ADR-0002 froze the registry identity at admission. A renamed or transferred
11+
Repository kept its old `owner/repo` in every table and URL, and
12+
`repos.source_owner` and `repos.source_repo` carried the new name for [GitHub](https://github.com)
13+
reads.
14+
15+
That left pages and run commands on names GitHub no longer serves. On
16+
2026-10-07 the run sweep found 229 Skills in 13 Repositories failing on GitHub's
17+
301, `facebook/react` among them. Production held 503 Repositories under a name
18+
that GitHub had moved. In two cases the new name was already a separate
19+
registry Repository with the same Skills: `hyf0/vue-skills` and
20+
`vuejs-ai/skills`, and `shadcn/ui` and `shadcn-ui/ui`. Those were duplicate
21+
indexable pages.
22+
23+
## Decision
24+
25+
The registry follows GitHub. When sync reads a Repository and GitHub answers
26+
with another name, every row moves to that name in one D1 batch.
27+
28+
- GitHub's numeric Repository ID survives renames and transfers. Sync stores it
29+
in `repos.repository_id` and records it on the alias.
30+
- The old name stays as a `repo_aliases` row. `/gh/<old-owner>/<old-repo>`{lang="html"} and
31+
every path under it answer 301 to the same path under the new name, in one
32+
hop. A later move rewrites older aliases to the newest name. A move back
33+
deletes the alias.
34+
- When the registry already holds the new name, the rows merge into it. A clash
35+
keeps the row under the new name.
36+
- A root Skill takes the Repository name, so a rename renames it. The alias
37+
keeps both names, so its old page redirects too.
38+
- `skilld run <old-owner>/<old-repo>/<skill>`{lang="html"} keeps working. The registry
39+
answers the old name with the new identity, and delivery follows GitHub's
40+
301 to the Repository ID.
41+
- Discovery evidence, skills.sh observations, and sync checkpoints keep the
42+
name they recorded.
43+
44+
`layers/registry/server/utils/repository-move.ts` holds the move.
45+
`scripts/move-repositories.ts` runs the same statements as a one-off backfill.
46+
47+
## Consequences
48+
49+
Pages, sitemaps, and run commands show the name GitHub serves. The 301 carries
50+
the old URL's links to the new one. The move adds no indexable page: each Skill
51+
keeps its admission, and a merge removes a duplicate. VISION principle 2 holds.
52+
53+
Every moved Skill is queued in `skill_dirty`, so trust and indexability are
54+
recomputed for the new identity. The `embedding` marker stays under the old
55+
name. The vector ID hashes the name, so the nightly prune deletes the old vector
56+
and the next generation embeds the new name.
57+
58+
Static lists keyed by name, such as `official-repos.ts` and the track pins in
59+
`clusters.ts`, must name the new Repository. An official Repository lists both
60+
names until no row carries the old one.
61+
62+
Moves come from GitHub, not from a person. A Repository whose old name GitHub
63+
gives to another Repository becomes live again once the registry admits it, and
64+
its alias stops redirecting.
65+
66+
Cull path: there is none while old URLs receive traffic. Dropping
67+
`repo_aliases` ends every redirect.
68+
69+
## Rejected alternatives
70+
71+
Keeping ADR-0002 meant run commands and pages named Repositories GitHub no
72+
longer serves, and duplicate Repositories stayed as duplicate pages.
73+
74+
A redirect without a move would answer every page view with a lookup and keep
75+
two names for one Repository in every table.

‎docs/arch/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,6 @@ See [ADR-0007](../adr/0007-contract-in-the-cli-repository.md).
8787
| Framework page | `/frameworks/[name]` |
8888
| Comparison | `/compare/<slug>` |
8989

90+
A Repository that GitHub renamed or transferred moves to its new name, and every `/gh` URL under the old name answers 301 to the same path under the new one ([ADR-0015](../adr/0015-follow-moved-repositories.md)).
91+
9092
The legacy `/people/[handle]` and `/people/[handle]/collections/[slug]` URLs are 410 Gone (with a 301 special-case for `/people/harlanzw.com → /@harlanzw`).

‎layers/registry/server/data/clusters.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export const CLUSTERS: Cluster[] = [
153153
'ibelick/ui-skills/fixing-accessibility',
154154
'ibelick/ui-skills/baseline-ui',
155155
'nutlope/hallmark/hallmark',
156-
'shadcn/ui/shadcn',
156+
'shadcn-ui/ui/shadcn',
157157
],
158158
},
159159
{
@@ -200,7 +200,7 @@ export const CLUSTERS: Cluster[] = [
200200
pinnedExamples: [
201201
'dmmulroy/anti-slop/install-anti-slop',
202202
'cursor/plugins/thermo-nuclear-code-quality-review',
203-
'brianlovin/claude-config/deslop',
203+
'brianlovin/agent-config/deslop',
204204
'cursor/plugins/deslop',
205205
'davila7/claude-code-templates/deslop',
206206
// Registry identity follows `docs/SKILL.md`; its frontmatter name
@@ -264,7 +264,7 @@ export const CLUSTERS: Cluster[] = [
264264
'pbakaus/agent-reviews/resolve-agent-reviews',
265265
'obra/superpowers/requesting-code-review',
266266
'obra/superpowers/receiving-code-review',
267-
'brianlovin/claude-config/fix-sentry-issues',
267+
'brianlovin/agent-config/fix-sentry-issues',
268268
'dimillian/skills/review-and-simplify-changes',
269269
'github/awesome-copilot/refactor',
270270
],
@@ -594,7 +594,7 @@ export const FRAMEWORK_PINNED_SKILLS: string[] = [
594594
// /frameworks/react and /frameworks/nextjs
595595
'vercel-labs/agent-skills/react-best-practices',
596596
'vercel-labs/agent-skills/composition-patterns',
597-
'vercel-labs/vercel-plugin/nextjs',
597+
'vercel/vercel-plugin/nextjs',
598598
'deckardger/tanstack-agent-skills/tanstack-query',
599599
'vercel/next.js/next-cache-components-optimizer',
600600
]

‎layers/registry/server/data/official-repos.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export interface OfficialRepo {
1818
* Org entries seeded from skills.sh/official on 2026-03-30. User entries
1919
* seeded from registry on 2026-04-25, then maintained as a reviewed list.
2020
* Maintained manually.
21+
*
22+
* A Repository GitHub moved keeps its old name here beside the new one, so
23+
* its Skills stay official on both sides of the registry move (ADR-0015).
24+
* Delete an old name once no registry row carries it.
2125
*/
2226
export const officialRepos: OfficialRepo[] = [
2327
{ owner: 'anthropics', repo: 'skills', kind: 'org' },
@@ -29,7 +33,9 @@ export const officialRepos: OfficialRepo[] = [
2933
{ owner: 'base', repo: 'skills', kind: 'org' },
3034
{ owner: 'better-auth', repo: 'skills', kind: 'org' },
3135
{ owner: 'bitwarden', repo: 'ai-plugins', kind: 'org' },
36+
// Old name. ADR-0015 moves its rows to the entry below.
3237
{ owner: 'box', repo: 'box-for-ai', kind: 'org' },
38+
{ owner: 'box', repo: 'skills', kind: 'org' },
3339
{ owner: 'brave', repo: 'brave-search-skills', kind: 'org' },
3440
{ owner: 'browser-use', repo: 'browser-use', kind: 'org' },
3541
{ owner: 'browserbase', repo: 'skills', kind: 'org' },
@@ -46,11 +52,15 @@ export const officialRepos: OfficialRepo[] = [
4652
{ owner: 'elevenlabs', repo: 'skills', kind: 'org' },
4753
{ owner: 'encoredev', repo: 'skills', kind: 'org' },
4854
{ owner: 'expo', repo: 'skills', kind: 'org' },
55+
// Old name. ADR-0015 moves its rows to the entry below.
4956
{ owner: 'facebook', repo: 'react', kind: 'org' },
57+
{ owner: 'react', repo: 'react', kind: 'org' },
5058
{ owner: 'figma', repo: 'mcp-server-guide', kind: 'org' },
5159
{ owner: 'firebase', repo: 'agent-skills', kind: 'org' },
5260
{ owner: 'firecrawl', repo: 'cli', kind: 'org' },
61+
// Old name. ADR-0015 moves its rows to the entry below.
5362
{ owner: 'flutter', repo: 'skills', kind: 'org' },
63+
{ owner: 'flutter', repo: 'agent-plugins', kind: 'org' },
5464
{ owner: 'getsentry', repo: 'skills', kind: 'org' },
5565
{ owner: 'github', repo: 'awesome-copilot', kind: 'org' },
5666
{ owner: 'google-gemini', repo: 'gemini-skills', kind: 'org' },
@@ -61,7 +71,9 @@ export const officialRepos: OfficialRepo[] = [
6171
{ owner: 'kotlin', repo: 'kotlin-agent-skills', kind: 'org' },
6272
{ owner: 'langchain-ai', repo: 'langchain-skills', kind: 'org' },
6373
{ owner: 'langfuse', repo: 'skills', kind: 'org' },
74+
// Old name. ADR-0015 moves its rows to the entry below.
6475
{ owner: 'launchdarkly', repo: 'agent-skills', kind: 'org' },
76+
{ owner: 'launchdarkly', repo: 'ai-tooling', kind: 'org' },
6577
{ owner: 'livekit', repo: 'agent-skills', kind: 'org' },
6678
{ owner: 'makenotion', repo: 'claude-code-notion-plugin', kind: 'org' },
6779
{ owner: 'mapbox', repo: 'mapbox-agent-skills', kind: 'org' },
@@ -90,7 +102,9 @@ export const officialRepos: OfficialRepo[] = [
90102
{ owner: 'streamlit', repo: 'agent-skills', kind: 'org' },
91103
{ owner: 'stripe', repo: 'ai', kind: 'org' },
92104
{ owner: 'supabase', repo: 'agent-skills', kind: 'org' },
105+
// Old name. ADR-0015 moves its rows to the entry below.
93106
{ owner: 'sveltejs', repo: 'mcp', kind: 'org' },
107+
{ owner: 'sveltejs', repo: 'ai-tools', kind: 'org' },
94108
{ owner: 'vuejs-ai', repo: 'skills', kind: 'org' },
95109
{ owner: 'vueuse', repo: 'skills', kind: 'org' },
96110
{ owner: 'tavily-ai', repo: 'skills', kind: 'org' },
@@ -101,7 +115,9 @@ export const officialRepos: OfficialRepo[] = [
101115
{ owner: 'vercel', repo: 'ai', kind: 'org' },
102116
{ owner: 'vercel', repo: 'next.js', kind: 'org' },
103117
{ owner: 'vercel-labs', repo: 'agent-skills', kind: 'org' },
118+
// Old name. ADR-0015 moves its rows to the entry below.
104119
{ owner: 'vercel-labs', repo: 'vercel-plugin', kind: 'org' },
120+
{ owner: 'vercel', repo: 'vercel-plugin', kind: 'org' },
105121
{ owner: 'vercel-labs', repo: 'agent-browser', kind: 'org' },
106122
{ owner: 'webflow', repo: 'webflow-skills', kind: 'org' },
107123
{ owner: 'wix', repo: 'skills', kind: 'org' },
@@ -118,9 +134,12 @@ export const officialRepos: OfficialRepo[] = [
118134
{ owner: 'addyosmani', repo: 'web-quality-skills', kind: 'user' },
119135
{ owner: 'mattpocock', repo: 'skills', kind: 'user' },
120136
{ owner: 'ibelick', repo: 'ui-skills', kind: 'user' },
137+
// Old name. ADR-0015 moves its rows to the entry below.
121138
{ owner: 'brianlovin', repo: 'claude-config', kind: 'user' },
139+
{ owner: 'brianlovin', repo: 'agent-config', kind: 'user' },
122140
{ owner: 'dimillian', repo: 'skills', kind: 'user' },
123141
{ owner: 'onmax', repo: 'nuxt-skills', kind: 'user' },
142+
// Old name. ADR-0015 merges its rows into vuejs-ai/skills above.
124143
{ owner: 'hyf0', repo: 'vue-skills', kind: 'user' },
125144
{ owner: 'emilkowalski', repo: 'skills', kind: 'user' },
126145
{ owner: 'nutlope', repo: 'hallmark', kind: 'user' },
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
indexRepositoryAliases,
3+
loadRepositoryAliases,
4+
repositoryPathSegments,
5+
resolveMovedRepositoryRoute,
6+
} from '../utils/repository-aliases'
7+
8+
/**
9+
* Answer every URL under a moved Repository with a 301 to its new name, so
10+
* the old URLs pass their link equity on (ADR-0015). Paths without an owner
11+
* and a Repository segment skip the cache read.
12+
*/
13+
export default defineEventHandler(async (event) => {
14+
const url = getRequestURL(event)
15+
if (!repositoryPathSegments(url.pathname))
16+
return
17+
18+
const db = event.context.platform?.db
19+
if (!db)
20+
return
21+
22+
const aliases = await loadRepositoryAliases(useStorage('cache'), db)
23+
if (!aliases?.length)
24+
return
25+
26+
const decision = resolveMovedRepositoryRoute(url.pathname, url.search, indexRepositoryAliases(aliases))
27+
if (decision._tag === 'redirect')
28+
return sendRedirect(event, decision.location, 301)
29+
})

‎layers/registry/server/presenters/repository-v1.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ function byLastChange(left: ProfileSkill, right: ProfileSkill): number {
2828
/**
2929
* One Repository profile.
3030
*
31-
* `source` is where the Repository lives on GitHub now. The registry keeps the
32-
* identity it first indexed (ADR-0002), and the two differ after a rename or
33-
* a transfer.
31+
* `source` is where the Repository lives on GitHub now. After a rename or a
32+
* transfer the two differ until sync moves the registry rows (ADR-0015).
3433
*/
3534
export function presentRepository(
3635
profile: LegacyOwnerProfile,

‎layers/registry/server/utils/embedding-parity.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,10 @@ export async function runEmbeddingParityCheck(
328328
* hashes owner/repo/name with no sha in it, so any skill still carrying an
329329
* `embedding` marker while failing the eligibility predicate is exactly one
330330
* strandable vector, addressable by id without reading the index at all.
331+
*
332+
* A marker with no Skill row at all is one too. A Repository move leaves the
333+
* marker under the old name on purpose (`repository-move.ts`), because the
334+
* vector ID still hashes that name.
331335
*/
332336
const PRUNE_CANDIDATE_SQL = `
333337
SELECT s.owner, s.repo, s.name
@@ -345,7 +349,15 @@ const PRUNE_CANDIDATE_SQL = `
345349
AND s.rendered_status = 'ok'
346350
AND s.seo_indexable = 1
347351
)
348-
ORDER BY s.owner ASC, s.repo ASC, s.name ASC
352+
UNION ALL
353+
SELECT marker.owner, marker.repo, marker.name
354+
FROM skill_generated marker
355+
WHERE marker.kind = 'embedding'
356+
AND NOT EXISTS (
357+
SELECT 1 FROM skills s
358+
WHERE s.owner = marker.owner AND s.repo = marker.repo AND s.name = marker.name
359+
)
360+
ORDER BY owner ASC, repo ASC, name ASC
349361
`
350362

351363
/**

‎layers/registry/server/utils/github-client.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export async function getRepo(
234234
}
235235

236236
interface RepoSummaryGql {
237+
databaseId: number | null
237238
name: string
238239
nameWithOwner: string
239240
url: string
@@ -251,16 +252,28 @@ interface RepoSummaryGql {
251252
} | null
252253
}
253254

254-
const REPO_SUMMARY_FIELDS = `name nameWithOwner url owner{login}
255+
const REPO_SUMMARY_FIELDS = `databaseId name nameWithOwner url owner{login}
255256
description stargazerCount forkCount pushedAt createdAt isArchived isFork
256257
defaultBranchRef{name target{... on Commit{oid tree{oid}}}}`
257258

258259
export interface RepoSummary {
259260
meta: RepoMeta
261+
/**
262+
* GitHub's numeric Repository ID. It survives every rename and transfer, so
263+
* it confirms that an old name and a new name are one Repository.
264+
*/
265+
repositoryId: number
260266
headTreeSha: string | null
261267
}
262268

263-
function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary {
269+
/**
270+
* Parse one GraphQL Repository into a summary, or null without a Repository
271+
* ID. The schema allows a null ID, and a Repository without one cannot be
272+
* followed across a move.
273+
*/
274+
function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary | null {
275+
if (typeof r.databaseId !== 'number')
276+
return null
264277
const branch = r.defaultBranchRef?.name || 'main'
265278
const meta: RepoMeta = {
266279
name: r.name,
@@ -276,7 +289,7 @@ function repoSummaryFromGql(r: RepoSummaryGql): RepoSummary {
276289
archived: r.isArchived,
277290
fork: r.isFork,
278291
}
279-
return { meta, headTreeSha: r.defaultBranchRef?.target?.tree.oid ?? null }
292+
return { meta, repositoryId: r.databaseId, headTreeSha: r.defaultBranchRef?.target?.tree.oid ?? null }
280293
}
281294

282295
/**
@@ -311,7 +324,11 @@ export async function getRepoSummary(
311324
const r = out.data?.repository
312325
if (!r)
313326
return { status: 404, data: null, rateLimit: out.rateLimit, notModified: false }
314-
return { status: 200, data: repoSummaryFromGql(r), rateLimit: out.rateLimit, notModified: false }
327+
// A Repository without an ID fails like any other malformed answer.
328+
const summary = repoSummaryFromGql(r)
329+
return summary
330+
? { status: 200, data: summary, rateLimit: out.rateLimit, notModified: false }
331+
: { status: 502, data: null, rateLimit: out.rateLimit, notModified: false }
315332
}
316333

317334
/**
@@ -377,6 +394,8 @@ export async function getRepoSummariesBatch(
377394
return { _tag: 'failed', status: 502, rateLimit, requests: sent }
378395
for (let i = 0; i < batch.length; i++) {
379396
const r = out.data?.[`r${i}`]
397+
// A summary without a usable Repository ID cannot be followed across a
398+
// move, so it reads as no summary and the per-repository sync owns it.
380399
summaries.push(r ? repoSummaryFromGql(r) : null)
381400
}
382401
}

0 commit comments

Comments
 (0)