Skip to content

Commit 5561c04

Browse files
committed
feat: add GitHub Release workflow and remote skill installer
- release.yml: generates and publishes skill ZIPs on version tags (v*) - install-from-release.js: installs skills from a release without cloning - Tracks installed version in ~/.claude/.webperf-snippets-version
1 parent 051a2d1 commit 5561c04

3 files changed

Lines changed: 221 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
jobs:
9+
release:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: write
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Setup Node.js
19+
uses: actions/setup-node@v4
20+
with:
21+
node-version: '20'
22+
23+
- name: Install dependencies
24+
run: npm ci
25+
26+
- name: Generate skills
27+
run: node scripts/generate-skills.js
28+
29+
- name: Package skills
30+
run: |
31+
cd skills
32+
for dir in */; do
33+
skill_name="${dir%/}"
34+
zip -r "../${skill_name}.zip" "$dir"
35+
done
36+
cd ..
37+
zip -r "webperf-skills-all.zip" skills/
38+
39+
- name: Create GitHub Release
40+
uses: softprops/action-gh-release@v2
41+
with:
42+
generate_release_notes: true
43+
files: |
44+
webperf-skills-all.zip
45+
webperf.zip
46+
webperf-core-web-vitals.zip
47+
webperf-loading.zip
48+
webperf-interaction.zip
49+
webperf-media.zip
50+
webperf-resources.zip
51+
52+
# Uncomment when ready to notify downstream repos instantly
53+
# - name: Notify downstream repositories
54+
# uses: peter-evans/repository-dispatch@v3
55+
# with:
56+
# token: ${{ secrets.DISPATCH_TOKEN }}
57+
# repository: nucliweb/chrome-devtools-mcp
58+
# event-type: webperf-snippets-release
59+
# client-payload: '{"version": "${{ github.ref_name }}"}'

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"generate-skills": "node scripts/generate-skills.js",
1212
"install-skills": "node scripts/install-skills.js",
1313
"install-global": "node scripts/install-global.js",
14+
"install-from-release": "node scripts/install-from-release.js",
1415
"test": "echo \"Error: no test specified\" && exit 1"
1516
},
1617
"keywords": [

scripts/install-from-release.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Install WebPerf Skills from a GitHub Release.
4+
*
5+
* Downloads the skills package from a specific release tag (or latest)
6+
* and installs them to ~/.claude/skills without cloning the repository.
7+
*
8+
* Usage:
9+
* node scripts/install-from-release.js # latest release
10+
* node scripts/install-from-release.js v1.2.0 # specific version
11+
*
12+
* Or via npx (no clone needed):
13+
* npx github:nucliweb/webperf-snippets/scripts/install-from-release.js
14+
*/
15+
16+
const fs = require('fs')
17+
const path = require('path')
18+
const os = require('os')
19+
const https = require('https')
20+
const { execSync } = require('child_process')
21+
22+
const REPO = 'nucliweb/webperf-snippets'
23+
const ASSET_NAME = 'webperf-skills-all.zip'
24+
const INSTALL_DIR = path.join(os.homedir(), '.claude', 'skills')
25+
const VERSION_FILE = path.join(os.homedir(), '.claude', '.webperf-snippets-version')
26+
27+
const requestedVersion = process.argv[2] || 'latest'
28+
29+
async function fetchJson(url) {
30+
return new Promise((resolve, reject) => {
31+
const options = {
32+
headers: {
33+
'User-Agent': 'webperf-snippets-installer',
34+
Accept: 'application/vnd.github.v3+json',
35+
},
36+
}
37+
https.get(url, options, (res) => {
38+
if (res.statusCode === 302 || res.statusCode === 301) {
39+
return fetchJson(res.headers.location).then(resolve).catch(reject)
40+
}
41+
let data = ''
42+
res.on('data', (chunk) => (data += chunk))
43+
res.on('end', () => {
44+
try {
45+
resolve(JSON.parse(data))
46+
} catch {
47+
reject(new Error(`Failed to parse response from ${url}`))
48+
}
49+
})
50+
res.on('error', reject)
51+
})
52+
})
53+
}
54+
55+
async function downloadFile(url, dest) {
56+
return new Promise((resolve, reject) => {
57+
const options = {
58+
headers: { 'User-Agent': 'webperf-snippets-installer' },
59+
}
60+
https.get(url, options, (res) => {
61+
if (res.statusCode === 302 || res.statusCode === 301) {
62+
return downloadFile(res.headers.location, dest).then(resolve).catch(reject)
63+
}
64+
const file = fs.createWriteStream(dest)
65+
res.pipe(file)
66+
file.on('finish', () => file.close(resolve))
67+
file.on('error', reject)
68+
})
69+
})
70+
}
71+
72+
async function getReleaseInfo(version) {
73+
const url =
74+
version === 'latest'
75+
? `https://api.github.com/repos/${REPO}/releases/latest`
76+
: `https://api.github.com/repos/${REPO}/releases/tags/${version}`
77+
return fetchJson(url)
78+
}
79+
80+
async function main() {
81+
console.log(`\nWebPerf Skills installer`)
82+
console.log(`Repository: https://github.com/${REPO}\n`)
83+
84+
console.log(`Fetching release info (${requestedVersion})...`)
85+
const release = await getReleaseInfo(requestedVersion)
86+
const version = release.tag_name
87+
88+
const currentVersion = fs.existsSync(VERSION_FILE)
89+
? fs.readFileSync(VERSION_FILE, 'utf8').trim()
90+
: null
91+
92+
if (currentVersion === version) {
93+
console.log(`Skills are already at version ${version}. Nothing to do.`)
94+
return
95+
}
96+
97+
const asset = release.assets.find((a) => a.name === ASSET_NAME)
98+
if (!asset) {
99+
throw new Error(`Asset "${ASSET_NAME}" not found in release ${version}`)
100+
}
101+
102+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webperf-skills-'))
103+
const zipPath = path.join(tmpDir, ASSET_NAME)
104+
105+
console.log(`Downloading ${version}...`)
106+
await downloadFile(asset.browser_download_url, zipPath)
107+
108+
console.log(`Installing to ${INSTALL_DIR}...`)
109+
fs.mkdirSync(INSTALL_DIR, { recursive: true })
110+
111+
execSync(`unzip -o "${zipPath}" -d "${tmpDir}"`, { stdio: 'pipe' })
112+
113+
const skillsExtracted = path.join(tmpDir, 'skills')
114+
for (const entry of fs.readdirSync(skillsExtracted)) {
115+
const src = path.join(skillsExtracted, entry)
116+
const dest = path.join(INSTALL_DIR, entry)
117+
if (fs.statSync(src).isDirectory()) {
118+
if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true })
119+
copyDir(src, dest)
120+
}
121+
}
122+
123+
fs.rmSync(tmpDir, { recursive: true, force: true })
124+
125+
fs.writeFileSync(VERSION_FILE, version, 'utf8')
126+
127+
console.log(`\nSkills installed successfully (${version})`)
128+
console.log(`Location: ${INSTALL_DIR}`)
129+
130+
if (currentVersion) {
131+
console.log(`Updated from ${currentVersion} to ${version}`)
132+
}
133+
134+
const installedSkills = fs
135+
.readdirSync(INSTALL_DIR)
136+
.filter((f) => fs.statSync(path.join(INSTALL_DIR, f)).isDirectory())
137+
138+
console.log('\nInstalled skills:')
139+
installedSkills.forEach((name) => console.log(` - ${name}`))
140+
console.log(
141+
'\nAdd them to your .claude/settings.json under "skills" to use in Claude Code.'
142+
)
143+
}
144+
145+
function copyDir(src, dest) {
146+
fs.mkdirSync(dest, { recursive: true })
147+
for (const entry of fs.readdirSync(src)) {
148+
const srcPath = path.join(src, entry)
149+
const destPath = path.join(dest, entry)
150+
if (fs.statSync(srcPath).isDirectory()) {
151+
copyDir(srcPath, destPath)
152+
} else {
153+
fs.copyFileSync(srcPath, destPath)
154+
}
155+
}
156+
}
157+
158+
main().catch((err) => {
159+
console.error(`\nError: ${err.message}`)
160+
process.exit(1)
161+
})

0 commit comments

Comments
 (0)