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
30 changes: 26 additions & 4 deletions .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ jobs:
publish:
runs-on: ubuntu-latest
environment: npm-publish
# id-token: the OIDC token of the run, which npm (trusted publishing) and the MCP Registry
# accept instead of a stored secret
permissions:
contents: read
id-token: write

steps:
- name: Checkout repository
Expand All @@ -19,9 +22,12 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
node-version: '24'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'

# Trusted publishing needs npm 11.5.1 or later
- name: Update npm
run: npm install -g npm@^11.5.1

- name: Check the version of the tag
run: node scripts/check-tag-version.js "$GITHUB_REF_NAME"
Expand All @@ -42,8 +48,24 @@ jobs:
- name: Prepare snippets for publishing
run: cp -r snippets cli/

# Trusted publishing: npm exchanges the OIDC token of this workflow for a short-lived one, and adds
# the provenance of the package
- name: Publish to npm
run: npm publish
working-directory: cli
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Install mcp-publisher
run: curl -sSL "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher

# The registry checks that the npm package of cli/server.json carries its name in mcpName, so it
# waits for npm to list the version it has just published
- name: Publish to the MCP Registry
working-directory: cli
run: |
../mcp-publisher login github-oidc
for attempt in 1 2 3 4 5 6; do
../mcp-publisher publish && exit 0
echo "Attempt $attempt failed, npm may not list the version yet"
sleep 30
done
exit 1
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "webperf-snippets",
"version": "1.0.0",
"mcpName": "io.github.nucliweb/webperf-snippets",
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
"type": "module",
"bin": "./src/bin.js",
Expand Down
39 changes: 39 additions & 0 deletions cli/server.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.nucliweb/webperf-snippets",
"title": "WebPerf Snippets",
"description": "Measure Core Web Vitals and debug web performance with 56 snippets in a headless Chromium",
"websiteUrl": "https://webperf-snippets.net",
"repository": {
"url": "https://github.com/nucliweb/webperf-snippets",
"source": "github",
"subfolder": "cli"
},
"version": "1.0.0",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "webperf-snippets",
"version": "1.0.0",
"transport": {
"type": "stdio"
},
"packageArguments": [
{
"type": "positional",
"value": "mcp"
}
],
"environmentVariables": [
{
"name": "CRUX_API_KEY",
"description": "Chrome UX Report API key for the field data of get_crux; the other tools work without it",
"isRequired": false,
"isSecret": true,
"format": "string"
}
]
}
]
}
15 changes: 13 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ The repository publishes three parts, each with its own version and tag:
| Part | Version in | Tag | Workflow | Publishes |
|---|---|---|---|---|
| Collection and skills | [package.json](../package.json) | `v*` | [release.yml](../.github/workflows/release.yml) | GitHub Release with the skill zips |
| CLI | [cli/package.json](../cli/package.json) | `cli-v*` | [publish-cli.yml](../.github/workflows/publish-cli.yml) | `webperf-snippets` on npm |
| CLI | [cli/package.json](../cli/package.json) | `cli-v*` | [publish-cli.yml](../.github/workflows/publish-cli.yml) | `webperf-snippets` on npm, and its MCP server in the MCP Registry |
| DevTools extension | [extension/manifest.json](../extension/manifest.json) | `extension-v*` | [release-extension.yml](../.github/workflows/release-extension.yml) | GitHub prerelease with the extension zip |

Each workflow first runs `node scripts/check-tag-version.js <tag>`, which fails when the tag does not match the version of its file, so a mistyped tag publishes nothing. Run it locally before pushing a tag:
Each workflow first runs `node scripts/check-tag-version.js <tag>`, which fails when the tag does not match the version of its file, so a mistyped tag publishes nothing. For the CLI it also checks [cli/server.json](../cli/server.json): its version, the version of the npm package it lists, and its name, which has to be the `mcpName` of `cli/package.json`. Run it locally before pushing a tag:

```bash
node scripts/check-tag-version.js cli-v1.1.0
Expand Down Expand Up @@ -70,6 +70,16 @@ git push origin cli-v1.1.0

The workflow runs the CLI unit and E2E tests, copies `snippets/` into `cli/` and publishes from there.

It publishes with no stored secret. npm accepts the OIDC token of the run through trusted publishing, configured on npmjs.com for this repository, `publish-cli.yml` and the `npm-publish` environment, and adds the provenance of the package. Then `mcp-publisher` logs in to the MCP Registry with the same token (`login github-oidc`, which allows the `io.github.nucliweb/*` names) and publishes `cli/server.json`. The registry checks that the npm package carries that name in `mcpName`, so the step retries for three minutes while npm lists the new version.

If npm published and the registry step failed, publish `cli/server.json` from a clone instead of tagging again, since npm rejects a version twice:

```bash
cd cli
mcp-publisher login github
mcp-publisher publish
```

## DevTools extension (`extension-v*`)

1. Update the version in [extension/manifest.json](../extension/manifest.json) and merge the pull request.
Expand All @@ -90,3 +100,4 @@ The workflow tests the zip and publishes it on a GitHub prerelease, so it never
- `_meta.js` entries drift from the actual MDX files
- Published snippet counts in `README.md`, `SKILLS.md`, or `skills/webperf/SKILL.md` are outdated
- The CLI publish waits forever: the `npm-publish` environment needs its approval.
- The CLI publish fails at npm with an authentication error: the trusted publisher on npmjs.com has to name this repository, `publish-cli.yml` and the `npm-publish` environment.
26 changes: 22 additions & 4 deletions scripts/check-tag-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,29 @@
// cli-v* the CLI on npm cli/package.json
// extension-v* the DevTools extension extension/manifest.json
//
// The CLI carries its MCP server too: cli/server.json describes it for the MCP Registry, which the
// publish workflow updates after npm. Its version, the one of the npm package it lists and its name
// (the mcpName of cli/package.json, which the registry checks on npm) have to match as well.
//
// Run: node scripts/check-tag-version.js <tag> [root]

const fs = require('node:fs')
const path = require('node:path')

const readJson = (root, file) => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'))

function checkMcpServer(root, version) {
const server = readJson(root, 'cli/server.json')
if (server.version !== version) return `The version ${server.version} of cli/server.json does not match the version ${version} of cli/package.json`
const pkg = (server.packages ?? []).findIndex((p) => p.version !== version)
if (pkg !== -1) return `packages[${pkg}].version ${server.packages[pkg].version} of cli/server.json does not match the version ${version} of cli/package.json`
const { mcpName } = readJson(root, 'cli/package.json')
if (server.name !== mcpName) return `The name ${server.name} of cli/server.json is not the mcpName ${mcpName} of cli/package.json`
return null
}

const PARTS = [
{ prefix: 'cli-v', file: 'cli/package.json' },
{ prefix: 'cli-v', file: 'cli/package.json', also: checkMcpServer },
{ prefix: 'extension-v', file: 'extension/manifest.json' },
{ prefix: 'v', file: 'package.json' },
]
Expand All @@ -21,9 +37,11 @@ function checkTag(tag, root = path.join(__dirname, '..')) {
const part = PARTS.find(({ prefix }) => tag.startsWith(prefix))
if (!part) return { ok: false, message: `${tag} is not a release tag (v*, cli-v* or extension-v*)` }

const version = JSON.parse(fs.readFileSync(path.join(root, part.file), 'utf8')).version
if (tag.slice(part.prefix.length) === version) return { ok: true, file: part.file, version }
return { ok: false, message: `The tag ${tag} does not match the version ${version} of ${part.file}` }
const version = readJson(root, part.file).version
if (tag.slice(part.prefix.length) !== version) return { ok: false, message: `The tag ${tag} does not match the version ${version} of ${part.file}` }
const mismatch = part.also?.(root, version)
if (mismatch) return { ok: false, message: mismatch }
return { ok: true, file: part.file, version }
}

function main([tag, root] = process.argv.slice(2)) {
Expand Down
29 changes: 26 additions & 3 deletions scripts/check-tag-version.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { checkTag } = require('./check-tag-version')

// A repository with the three versioned parts: the collection, the CLI and the extension
function repo({ collection = '2.0.1', cli = '1.0.0', extension = '0.1.1' } = {}) {
// A repository with the three versioned parts: the collection, the CLI (with the server.json of its
// MCP server) and the extension
const MCP_NAME = 'io.github.nucliweb/webperf-snippets'
function repo({ collection = '2.0.1', cli = '1.0.0', extension = '0.1.1', server = cli, serverPackage = server, serverName = MCP_NAME } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tag-version-'))
fs.mkdirSync(path.join(root, 'cli'))
fs.mkdirSync(path.join(root, 'extension'))
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: collection }))
fs.writeFileSync(path.join(root, 'cli', 'package.json'), JSON.stringify({ version: cli }))
fs.writeFileSync(path.join(root, 'cli', 'package.json'), JSON.stringify({ version: cli, mcpName: MCP_NAME }))
fs.writeFileSync(
path.join(root, 'cli', 'server.json'),
JSON.stringify({ name: serverName, version: server, packages: [{ registryType: 'npm', identifier: 'webperf-snippets', version: serverPackage }] })
)
fs.writeFileSync(path.join(root, 'extension', 'manifest.json'), JSON.stringify({ version: extension }))
return root
}
Expand All @@ -27,6 +33,23 @@ test('a cli-v* tag matches the version of cli/package.json, not the one of the c
assert.equal(checkTag('cli-v2.0.1', repo()).ok, false)
})

test('a cli-v* tag also matches the version of cli/server.json and of the npm package it lists', () => {
assert.equal(checkTag('cli-v1.1.0', repo({ cli: '1.1.0' })).ok, true)
const server = checkTag('cli-v1.1.0', repo({ cli: '1.1.0', server: '1.0.0' }))
assert.equal(server.ok, false)
assert.match(server.message, /1\.0\.0 of cli\/server\.json/)
const pkg = checkTag('cli-v1.1.0', repo({ cli: '1.1.0', serverPackage: '1.0.0' }))
assert.equal(pkg.ok, false)
assert.match(pkg.message, /packages\[0\]\.version/)
})

test('the name of cli/server.json is the mcpName of cli/package.json, which the MCP Registry checks', () => {
const { ok, message } = checkTag('cli-v1.0.0', repo({ serverName: 'io.github.someone/else' }))
assert.equal(ok, false)
assert.match(message, /io\.github\.someone\/else/)
assert.match(message, /mcpName/)
})

test('an extension-v* tag matches the version of extension/manifest.json', () => {
assert.deepEqual(checkTag('extension-v0.1.1', repo()), { ok: true, file: 'extension/manifest.json', version: '0.1.1' })
assert.equal(checkTag('extension-v0.1.2', repo()).ok, false)
Expand Down
Loading