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
10 changes: 6 additions & 4 deletions docs/runbooks/skillgen.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A signed-in maintainer turns Skillgen on for each repository, and `skillgen_repo
Turning it on needs admin or maintain access, a public repository, and a published package with a Skill.
`workers/skill-harness/src/package-skills.ts` holds the package rule. The site and the Worker both import it.
A package sits at the root or under `packages/<name>/`, with its Skill in `skills/<dir>/SKILL.md` beside its `package.json`.
Skillgen supports npm packages only.
Skillgen supports [npm](https://npmjs.com) packages only.

Before the Worker queues a job, it asks `POST /api/internal/skillgen/opt-ins` which repositories in the event opted in.
If the site does not answer, the webhook returns 503 and queues nothing. Redeliver the event from the App settings.
Expand All @@ -32,7 +32,7 @@ pnpm exec tsx scripts/skill-github-app-register.ts \
~/scratch/skilld-github-app
```

Open the printed local URL in your personal GitHub browser.
Open the printed local URL in your personal [GitHub](https://github.com) browser.
The helper creates a public App owned by `skilld-dev`.
Use a GitHub account allowed to register Apps for that organization.
Other accounts can install the App on selected repositories.
Expand All @@ -57,6 +57,8 @@ New tag creation starts another job automatically.
Each job lists the packages with a Skill on the default branch.
It reads each one's `package.json` from the exact tag commit, and skips private packages.
The tag must equal a package version, as `1.2.3`, `v1.2.3`, or `name@1.2.3`.
If that version is older than the package version on the default branch, the job skips with `TAG_OLDER_THAN_DEFAULT_BRANCH`.
So a maintenance release, such as `1.3.13` while `main` is at `2.0.0-beta.14`, never rewrites the newer Skill.
If several packages match one tag, the job splits into one job per package.
Each split job opens its own pull request on a branch named after the package directory.
If the package publication follows the tag, the job waits for it.
Expand Down Expand Up @@ -86,9 +88,9 @@ The App never replaces a branch that someone could have edited.
## Inspect a job

The webhook response includes job IDs.
Read `/github/jobs/<id>` with `Authorization: Bearer <PROOF_TOKEN>`.
Read `/github/jobs/<id>`{lang="html"} with `Authorization: Bearer <PROOF_TOKEN>`{lang="html"}.
The response includes the phase or final outcome.
Read `/proofs/<id>` with the same token for the Harness result.
Read `/proofs/<id>`{lang="html"} with the same token for the Harness result.

If a job failed, POST `/github/jobs/<id>/retry` with the same operator token.
Retry waits until the previous Harness job finishes.
Expand Down
2 changes: 1 addition & 1 deletion layers/marketing/app/pages/skillgen.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const codeClass = 'font-mono text-xs text-default'
Up to 9 files and 64 KiB in total: <code :class="codeClass">SKILL.md</code> and Markdown files under <code :class="codeClass">references/</code>.
</li>
<li>
Release tags that match the package version, such as <code :class="codeClass">1.4.0</code>, <code :class="codeClass">v1.4.0</code> or <code :class="codeClass">name@1.4.0</code>. If one tag releases several packages with Skills, each gets its own pull request.
Release tags that match the package version, such as <code :class="codeClass">1.4.0</code>, <code :class="codeClass">v1.4.0</code> or <code :class="codeClass">name@1.4.0</code>. If one tag releases several packages with Skills, each gets its own pull request. A tag older than the version on your default branch, such as a maintenance release, starts no run.
</li>
<li>
npm links each version to its tag commit, through provenance or <code :class="codeClass">gitHead</code>.
Expand Down
36 changes: 36 additions & 0 deletions workers/skill-harness/src/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,37 @@ async function readPackage(api: GithubRequest, prefix: string, packageDir: strin
return pkg?.success && !pkg.data.private ? pkg.data : undefined
}

/** Semver precedence of two versions that `packageSchema` accepted: negative when `a` is older. */
function compareVersions(a: string, b: string): number {
const split = (version: string) => {
const [core, pre] = version.split(/-(.*)/s)
return { core: core!.split('.').map(Number), pre: pre ? pre.split('.') : [] }
}
const left = split(a)
const right = split(b)
for (let index = 0; index < 3; index++) {
if (left.core[index] !== right.core[index])
return left.core[index]! - right.core[index]!
}
// A release outranks its prereleases.
if (!left.pre.length || !right.pre.length)
return right.pre.length - left.pre.length
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index++) {
const [x, y] = [left.pre[index], right.pre[index]]
if (x === undefined || y === undefined)
return x === undefined ? -1 : 1
if (x === y)
continue
const [nx, ny] = [/^\d+$/.test(x) ? Number(x) : undefined, /^\d+$/.test(y) ? Number(y) : undefined]
if (nx !== undefined && ny !== undefined)
return nx - ny
if (nx !== undefined || ny !== undefined)
return nx !== undefined ? -1 : 1
return x < y ? -1 : 1
}
return 0
}

/**
* Resolves a tag to the one package whose Skill it updates. Monorepos keep
* packages under `packages/`, so the Skills on the default branch name the
Expand Down Expand Up @@ -118,6 +149,11 @@ export async function prepareTag(request: TagRequest, api: GithubRequest, fetche
if (matches.length > 1)
return { _tag: 'Split', tag, packageDirs: matches.map(item => item.candidate.packageDir) }
const { candidate, pkg } = match
// The pull request targets the default branch. A tag from an older release
// line, such as a 1.x maintenance branch, would rewrite its newer Skill.
const current = await readPackage(api, prefix, candidate.packageDir, baseSha)
if (current?.name === pkg.name && compareVersions(pkg.version, current.version) < 0)
return { _tag: 'Skipped', reason: 'TAG_OLDER_THAN_DEFAULT_BRANCH' }
const skillRoot = packageSkillRoot(candidate, pkg.name)
if (skillRoot === undefined)
return { _tag: 'Skipped', reason: 'AMBIGUOUS_SKILL' }
Expand Down
8 changes: 5 additions & 3 deletions workers/skill-harness/src/package-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ export function packageSkillCandidates(paths: Iterable<string>): PackageSkillCan

/**
* The one Skill a package's runs update: its only Skill directory, or the one
* named after the package without its scope. Undefined when neither decides.
* named after the package. `generate-package-skill` names `@nuxtjs/seo` as
* `nuxtjs-seo`; older Skills drop the scope. Undefined when nothing decides.
*/
export function packageSkillRoot(candidate: PackageSkillCandidate, packageName: string): string | undefined {
if (candidate.skillRoots.length === 1)
return candidate.skillRoots[0]
const named = `${candidate.packageDir ? `${candidate.packageDir}/` : ''}skills/${packageName.split('/').at(-1)}`
return candidate.skillRoots.find(root => root === named)
const prefix = `${candidate.packageDir ? `${candidate.packageDir}/` : ''}skills/`
const names = [packageName.replace(/^@/, '').replace('/', '-'), packageName.split('/').at(-1)]
return names.map(name => `${prefix}${name}`).find(root => candidate.skillRoots.includes(root))
}

/** A release tag names a package version as `1.2.3`, `v1.2.3`, or `name@1.2.3`. */
Expand Down
36 changes: 36 additions & 0 deletions workers/skill-harness/test/github-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,34 @@ describe('tag preparation', () => {
const fetcher = async () => Response.json({ name: 'package', version: '1.0.0', gitHead: 'd'.repeat(40), dist: { integrity: 'unused' } })
expect(await prepareTag(request, api, fetcher)).toEqual({ _tag: 'Skipped', reason: 'PACKAGE_TAG_PROVENANCE_MISMATCH' })
})

/** The default branch is at `mainVersion`; the tag commit is at `tagVersion`. */
const releaseLine = (tagVersion: string, mainVersion: string) => async (path: string) => {
if (path.includes('/contents/package.json'))
return { encoding: 'base64', content: btoa(JSON.stringify({ name: 'package', version: path.endsWith(`ref=${base}`) ? mainVersion : tagVersion })) }
return api(path.replace(/\/commits\/v[^/]+$/, '/commits/v1.0.0'))
}
const publishedAs = (version: string) => async () => Response.json({ name: 'package', version, gitHead: target, dist: { integrity: 'unused' } })

it('skips a maintenance release older than the default branch, so it never rewrites the newer Skill', async () => {
const result = await prepareTag({ ...request, tag: 'v1.3.13' }, releaseLine('1.3.13', '2.0.0-beta.14'), publishedAs('1.3.13'))
expect(result).toEqual({ _tag: 'Skipped', reason: 'TAG_OLDER_THAN_DEFAULT_BRANCH' })
})

it.each([
['2.0.0-beta.14', '2.0.0-beta.14'],
['2.0.0-beta.15', '2.0.0-beta.14'],
['2.0.0', '2.0.0-beta.14'],
['2.0.0-beta.10', '2.0.0-beta.9'],
])('prepares tag %s while the default branch is at %s', async (tagVersion, mainVersion) => {
const result = await prepareTag({ ...request, tag: `v${tagVersion}` }, releaseLine(tagVersion, mainVersion), publishedAs(tagVersion))
expect(result).toMatchObject({ _tag: 'Prepared', value: { input: { spec: `package@${tagVersion}` } } })
})

it('skips a stable tag once the default branch moved to its next prerelease line', async () => {
const result = await prepareTag({ ...request, tag: 'v1.9.0' }, releaseLine('1.9.0', '2.0.0-alpha.1'), publishedAs('1.9.0'))
expect(result).toEqual({ _tag: 'Skipped', reason: 'TAG_OLDER_THAN_DEFAULT_BRANCH' })
})
})

describe('monorepo tag preparation', () => {
Expand Down Expand Up @@ -210,6 +238,14 @@ describe('monorepo tag preparation', () => {
.toEqual({ _tag: 'Split', tag: 'v5.3.16', packageDirs: ['packages/devtools-layer', 'packages/nuxt-seo'] })
})

it('picks the Skill named after a scoped package, scope included, when the package has several', async () => {
const api = monorepo('nuxt-seo', 'v5.3.16', {
'packages/nuxt-seo': { name: '@nuxtjs/seo', version: '5.3.16' },
}, ['packages/nuxt-seo/skills/nuxtjs-seo/SKILL.md', 'packages/nuxt-seo/skills/seo-audit/SKILL.md'])
const result = await prepareTag(nuxtSeoTag, api, published('@nuxtjs/seo', '5.3.16'))
expect(result).toMatchObject({ _tag: 'Prepared', value: { skillRoot: 'packages/nuxt-seo/skills/nuxtjs-seo' } })
})

it('prepares only the package a split job names', async () => {
const result = await prepareTag({ ...nuxtSeoTag, packagePath: 'packages/nuxt-seo' }, nuxtSeo, published('@nuxtjs/seo', '5.3.16'))
expect(result).toMatchObject({ _tag: 'Prepared', value: { packageDir: 'packages/nuxt-seo', skillRoot: 'packages/nuxt-seo/skills/nuxtjs-seo', input: { spec: '@nuxtjs/seo@5.3.16' } } })
Expand Down
Loading