diff --git a/docs/runbooks/skillgen.md b/docs/runbooks/skillgen.md index ab0cf3a3..2c103cf2 100644 --- a/docs/runbooks/skillgen.md +++ b/docs/runbooks/skillgen.md @@ -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//`, with its Skill in `skills//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. @@ -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. @@ -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. @@ -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/` with `Authorization: Bearer `. +Read `/github/jobs/`{lang="html"} with `Authorization: Bearer `{lang="html"}. The response includes the phase or final outcome. -Read `/proofs/` with the same token for the Harness result. +Read `/proofs/`{lang="html"} with the same token for the Harness result. If a job failed, POST `/github/jobs//retry` with the same operator token. Retry waits until the previous Harness job finishes. diff --git a/layers/marketing/app/pages/skillgen.vue b/layers/marketing/app/pages/skillgen.vue index 7d8952f0..de7530bf 100644 --- a/layers/marketing/app/pages/skillgen.vue +++ b/layers/marketing/app/pages/skillgen.vue @@ -66,7 +66,7 @@ const codeClass = 'font-mono text-xs text-default' Up to 9 files and 64 KiB in total: SKILL.md and Markdown files under references/.
  • - Release tags that match the package version, such as 1.4.0, v1.4.0 or name@1.4.0. If one tag releases several packages with Skills, each gets its own pull request. + Release tags that match the package version, such as 1.4.0, v1.4.0 or name@1.4.0. 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.
  • npm links each version to its tag commit, through provenance or gitHead. diff --git a/workers/skill-harness/src/github-client.ts b/workers/skill-harness/src/github-client.ts index d8f26f0c..b42cfdf6 100644 --- a/workers/skill-harness/src/github-client.ts +++ b/workers/skill-harness/src/github-client.ts @@ -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 @@ -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' } diff --git a/workers/skill-harness/src/package-skills.ts b/workers/skill-harness/src/package-skills.ts index b1ac969a..e692c449 100644 --- a/workers/skill-harness/src/package-skills.ts +++ b/workers/skill-harness/src/package-skills.ts @@ -42,13 +42,15 @@ export function packageSkillCandidates(paths: Iterable): 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`. */ diff --git a/workers/skill-harness/test/github-app.test.ts b/workers/skill-harness/test/github-app.test.ts index 72e15352..bfb027b5 100644 --- a/workers/skill-harness/test/github-app.test.ts +++ b/workers/skill-harness/test/github-app.test.ts @@ -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', () => { @@ -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' } } })