From d5e15c6e7184f0949296ed2f650af6337017d0b3 Mon Sep 17 00:00:00 2001 From: wbtiger <28288271@qq.com> Date: Sun, 17 May 2026 14:49:16 +0800 Subject: [PATCH] ci: add GitHub Release workflow with npm publish --- .github/workflows/release.yml | 198 ++++++++++++++++++++++++++++++++++ scripts/install.js | 150 ++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/install.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8be3183 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,198 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Build binaries + run: | + mkdir -p dist + VERSION=${GITHUB_REF#refs/tags/v} + + for pair in "darwin amd64" "darwin arm64" "linux amd64" "linux arm64"; do + GOOS=$(echo $pair | cut -d' ' -f1) + GOARCH=$(echo $pair | cut -d' ' -f2) + echo "Building ${GOOS}-${GOARCH}..." + GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "-s -w -X 'github.com/gitlink-org/gitlink-cli/cmd.Version=${VERSION}'" -o dist/gitlink-cli . + cd dist + tar -czf "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" gitlink-cli + rm gitlink-cli + cd .. + done + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: dist/*.tar.gz + generate_release_notes: true + + - name: Build npm package + run: | + VERSION=${GITHUB_REF#refs/tags/v} + mkdir -p npm-pkg/bin npm-pkg/scripts npm-pkg/skills + + # Copy skills + cp -r skills/* npm-pkg/skills/ + + # Copy bin wrappers + cp bin/cli.js npm-pkg/bin/ 2>/dev/null || cat > npm-pkg/bin/cli.js << 'NODEEOF' + #!/usr/bin/env node + const {execFileSync} = require("child_process"); + const path = require("path"); + const bin = path.join(__dirname, "..", "bin", "gitlink-cli"); + try { process.exit(execFileSync(bin, process.argv.slice(2), {stdio:"inherit"}).status); } + catch(e) { process.exit(e.status || 1); } + NODEEOF + + cat > npm-pkg/bin/install-skills.js << 'NODEEOF' + #!/usr/bin/env node + const {execSync} = require("child_process"); + const path = require("path"); + const skillsDir = path.join(__dirname, "..", "skills"); + try { + execSync(`npx skills add "${skillsDir}" -y -g`, {stdio:"inherit"}); + } catch(e) { + console.error("Failed to install skills:", e.message); + process.exit(1); + } + NODEEOF + + # Copy install.js + cp scripts/install.js npm-pkg/scripts/ 2>/dev/null || cat > npm-pkg/scripts/install.js << 'NODEEOF' + #!/usr/bin/env node + "use strict"; + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + const https = require("https"); + const http = require("http"); + const {execSync} = require("child_process"); + + const PACKAGE = require("../package.json"); + const VERSION = PACKAGE.version; + const BINARY_NAME = "gitlink-cli"; + const RELEASE_BASE = "https://github.com"; + const REPO_OWNER = "ccfos"; + const REPO_NAME = "gitlink-cli"; + + function getPlatformInfo() { + const platform = os.platform(); + const arch = os.arch(); + const pmap = {darwin:"darwin",linux:"linux",win32:"windows"}; + const amap = {x64:"amd64",arm64:"arm64"}; + const p=pmap[platform], a=amap[arch]; + if(!p||!a) throw new Error(`Unsupported: ${platform}-${arch}`); + return {platform:p,arch:a}; + } + + function fetch(url) { + return new Promise((resolve,reject) => { + const mod=url.startsWith("https")?https:http; + let count=0; + function req(u) { + if(++count>5) return reject(new Error("Too many redirects")); + mod.get(u,(res) => { + if([301,302,307,308].includes(res.statusCode)&&res.headers.location){ + let loc=res.headers.location; + if(loc.startsWith("/")){const p=new URL(u);loc=p.protocol+"//"+p.host+loc} + return req(loc); + } + if(res.statusCode!==200) return reject(new Error(`HTTP ${res.statusCode}`)); + const c=[];res.on("data",d=>c.push(d));res.on("end",()=>resolve(Buffer.concat(c))); + }).on("error",reject); + } + req(url); + }); + } + + async function main() { + try { + const {platform,arch} = getPlatformInfo(); + const binDir = path.join(__dirname,"..","bin"); + if(!fs.existsSync(binDir)) fs.mkdirSync(binDir,{recursive:true}); + const binaryPath = path.join(binDir,BINARY_NAME); + + if(fs.existsSync(binaryPath)) { + try { + const out = execSync(`"${binaryPath}" version`,{encoding:"utf-8",timeout:5000}); + if(out.includes(VERSION)) { console.log(`${BINARY_NAME} v${VERSION} already installed.`); return; } + } catch(e) {} + fs.unlinkSync(binaryPath); + } + + const assetName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`; + const url = `${RELEASE_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/v${VERSION}/${assetName}`; + console.log(`Downloading ${url}...`); + const data = await fetch(url); + const tmp = path.join(binDir,"dl.tar.gz"); + fs.writeFileSync(tmp,data); + execSync(`tar -xzf "${tmp}" -C "${binDir}"`,{stdio:"pipe"}); + fs.unlinkSync(tmp); + fs.chmodSync(binaryPath,0o755); + console.log(`${BINARY_NAME} v${VERSION} installed.`); + } catch(err) { + console.warn(`⚠ Binary download failed: ${err.message}`); + console.warn(`Skills are installed. Install binary manually:`); + console.warn(`npm run postinstall`); + } + } + main(); + NODEEOF + + # Create README + cp README.md npm-pkg/ + + # Create package.json + cat > npm-pkg/package.json << PKGEOF + { + "name": "@gitlink-ai/cli", + "version": "${VERSION}", + "description": "GitLink CLI — 面向 AI Agent 的 GitLink 命令行工具", + "main": "bin/cli.js", + "bin": { + "gitlink-cli": "./bin/cli.js", + "gitlink-cli-install-skills": "./bin/install-skills.js" + }, + "scripts": { + "postinstall": "node scripts/install.js" + }, + "files": [ + "bin/", + "scripts/", + "skills/", + "README.md", + "package.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/ccfos/gitlink-cli.git" + }, + "keywords": ["gitlink", "cli", "ai-agent", "skills"], + "author": "", + "license": "MulanPSL-2.0" + } + PKGEOF + + cd npm-pkg + npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/scripts/install.js b/scripts/install.js new file mode 100644 index 0000000..ceeb24a --- /dev/null +++ b/scripts/install.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +"use strict"; + +const os = require("os"); +const path = require("path"); +const fs = require("fs"); +const https = require("https"); +const http = require("http"); +const { execSync } = require("child_process"); + +const PACKAGE = require("../package.json"); +const VERSION = PACKAGE.version; +const BINARY_NAME = "gitlink-cli"; + +const RELEASE_BASE = "https://www.gitlink.org.cn"; +const REPO_OWNER = "Gitlink"; +const REPO_NAME = "gitlink-cli"; + +function getPlatformInfo() { + const platform = os.platform(); + const arch = os.arch(); + const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" }; + const archMap = { x64: "amd64", arm64: "arm64" }; + const goPlatform = platformMap[platform]; + const goArch = archMap[arch]; + if (!goPlatform || !goArch) { + throw new Error(`Unsupported platform: ${platform}-${arch}`); + } + return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" }; +} + +function fetch(url, options = {}) { + return new Promise((resolve, reject) => { + const maxRedirects = options.maxRedirects || 5; + let redirectCount = 0; + function doRequest(currentUrl) { + const mod = currentUrl.startsWith("https") ? https : http; + const req = mod.get(currentUrl, (res) => { + if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) { + redirectCount++; + if (redirectCount > maxRedirects) { reject(new Error("Too many redirects")); return; } + let redirectUrl = res.headers.location; + if (redirectUrl.startsWith("/")) { + const parsed = new URL(currentUrl); + redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`; + } + doRequest(redirectUrl); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + if (options.json) { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + try { resolve(JSON.parse(body)); } catch (e) { reject(new Error("JSON parse failed")); } + }); + } else { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve(Buffer.concat(chunks))); + } + }); + req.on("error", reject); + req.setTimeout(30000, () => { req.destroy(); reject(new Error("Timeout")); }); + } + doRequest(url); + }); +} + +async function findReleaseAsset(platform, arch) { + const archiveName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`; + const tagName = `v${VERSION}`; + const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`; + + try { + const releases = await fetch(apiUrl, { json: true }); + const rlist = Array.isArray(releases) ? releases : (releases && releases.releases ? releases.releases : []); + let release = rlist.find(r => r.tag_name === tagName || r.tag_name === VERSION); + if (!release && rlist.length > 0) release = rlist[0]; + + if (release && release.attachments) { + let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName); + if (!asset) { + const pattern = `_${platform}_${arch}.tar.gz`; + asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern)); + } + if (asset) { + let url = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`; + if (url.startsWith("/")) url = RELEASE_BASE + url; + return url; + } + } + } catch (e) {} + + return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`; +} + +async function downloadAndExtract(url, destDir, platform) { + const data = await fetch(url); + const archivePath = path.join(destDir, "download.tar.gz"); + fs.writeFileSync(archivePath, data); + execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" }); + fs.unlinkSync(archivePath); + + const binaryPath = path.join(destDir, BINARY_NAME); + if (!fs.existsSync(binaryPath)) { + const files = fs.readdirSync(destDir); + for (const file of files) { + const subPath = path.join(destDir, file, BINARY_NAME); + if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; } + } + } + if (!fs.existsSync(binaryPath)) throw new Error("Binary not found after extraction"); + fs.chmodSync(binaryPath, 0o755); +} + +async function main() { + const { platform, arch } = getPlatformInfo(); + const binDir = path.join(__dirname, "..", "bin"); + if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); } + + const binaryPath = path.join(binDir, BINARY_NAME); + if (fs.existsSync(binaryPath)) { + try { + const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 }); + if (output.includes(VERSION)) { + console.log(`${BINARY_NAME} v${VERSION} already installed.`); + return; + } + } catch (e) {} + fs.unlinkSync(binaryPath); + } + + try { + const downloadUrl = await findReleaseAsset(platform, arch); + await downloadAndExtract(downloadUrl, binDir, platform); + console.log(`${BINARY_NAME} v${VERSION} installed.`); + } catch (err) { + // Don't fail npm install — binary can be installed later + console.warn(`⚠ ${BINARY_NAME} binary download failed: ${err.message}`); + console.warn(` Skills are installed. You can install the binary manually later:`); + console.warn(` npm run postinstall`); + } +} + +main();