diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf9db47..a13b1ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest] runs-on: ${{ matrix.os }} timeout-minutes: 15 steps: @@ -29,10 +29,18 @@ jobs: - name: Vet run: go vet ./... - name: Test - run: go test -race -count=1 ./... + run: go test -timeout=120s -race -count=1 ./... package: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x64 + - os: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - uses: actions/checkout@v4 @@ -44,6 +52,10 @@ jobs: node-version: 22 - name: Check manifest and build the archives run: node scripts/package.mjs + - name: Verify Linux release archives + run: node --test --test-timeout=60000 scripts/package/*.test.mjs + - name: Start the packaged Linux language server + run: node scripts/package/smoke-linux.mjs dist/*-linux-${{ matrix.arch }}.zip - name: Validate against Lithe Store rules run: | git clone --depth 1 https://github.com/Lithe-IDEA/Lithe-store "$RUNNER_TEMP/store" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce4e709..2eb395d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,17 +25,21 @@ jobs: with: node-version: 22 - name: Test - run: go test -count=1 ./... + run: go test -timeout=120s -count=1 ./... - name: Build archives run: | set -o pipefail node scripts/package.mjs --expect-tag "$GITHUB_REF_NAME" | tee "$RUNNER_TEMP/checksums.txt" + - name: Verify Linux release archives + run: node --test --test-timeout=60000 scripts/package/*.test.mjs + - name: Start the packaged Linux x64 language server + run: node scripts/package/smoke-linux.mjs dist/*-linux-x64.zip - name: Publish release env: GH_TOKEN: ${{ github.token }} # Releases on plugin contract v0, which only the new desktop client # installs, and that client ships as desktop-v* pre-releases. - PRERELEASE_TAGS: v0.0.1 v0.0.2 v0.0.3 + PRERELEASE_TAGS: v0.0.1 v0.0.2 v0.0.3 v0.1.0 run: | prerelease=false for tag in $PRERELEASE_TAGS; do diff --git a/README.md b/README.md index 42878ab..41a2cd5 100644 --- a/README.md +++ b/README.md @@ -164,3 +164,7 @@ go run ./cmd/java-navigation --version ## 许可 Apache License 2.0,见 `LICENSE`。`internal/codenav` 等来自 Viff 的代码按 MIT 许可,见 `THIRD_PARTY_NOTICES.md`。 + +## Linux ARM64 / x64 + +Linux 构建、资源归属与原生验证入口见 [Linux 构建与验证](docs/linux-packaging.md)。 diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md new file mode 100644 index 0000000..71945f5 --- /dev/null +++ b/docs/feature-matrix.md @@ -0,0 +1,10 @@ +# 功能验证矩阵 + +## Linux 安装包 + +| 能力 | Linux ARM64 | Linux x64 | 验证入口 | +| --- | --- | --- | --- | +| 构建与安装包 | [已实现并通过原生 CI](https://github.com/Lithe-IDEA/lithe-plugin-java-navigation/actions/runs/43619383227) | [已实现并通过原生 CI](https://github.com/Lithe-IDEA/lithe-plugin-java-navigation/actions/runs/43619383227) | `.github/workflows/ci.yml`、[资源说明](linux-packaging.md) | +| 真实工具/语言服务器流程 | [已通过原生 CI](https://github.com/Lithe-IDEA/lithe-plugin-java-navigation/actions/runs/43619383227) | [已通过原生 CI](https://github.com/Lithe-IDEA/lithe-plugin-java-navigation/actions/runs/43619383227) | `scripts/package/` 和现有集成测试 | + +跨架构本地包检查通过不等于原生工作流验收。既有平台验证记录保持不变。 diff --git a/docs/linux-packaging.md b/docs/linux-packaging.md new file mode 100644 index 0000000..de8ec27 --- /dev/null +++ b/docs/linux-packaging.md @@ -0,0 +1,11 @@ +# Linux 构建与验证 + +`node scripts/package.mjs` 使用 `CGO_ENABLED=0` 交叉编译 Linux ARM64 与 x64 语言服务器,分别产生 `-linux-arm64.zip`、`-linux-x64.zip`。归档检查校验 ELF64 架构、包内启动路径与解压后的执行权限。 + +`node scripts/package/smoke-linux.mjs ` 在对应原生 Linux runner 上启动包内程序,完成 LSP initialize、文档符号与 shutdown,验证安装目录文件内容与权限不变,并清理进程组和临时工作区。 + +## 资源与验证边界 + +所有下载、解压、编译与归档都属于本插件仓库的构建阶段。`dist/` 为输出包;`.artifacts/`、`.cache/`、`target/` 或工具自身缓存为本地构建状态,不跨工作树共享可变状态。校验过的下载以固定来源与摘要识别,提取结果不作为可信下载源。运行时只读取已安装插件,缓存、日志、索引和工作区状态归用户可写目录。Linux 不使用 macOS 代码签名或 Sparkle;本次不改变 macOS 包签名与更新基线。 + +原生 ARM64 和 x64 验证由 `.github/workflows/ci.yml` 执行;本机的交叉编译与归档检查不能替代原生运行结果。只有目标平台 CI 成功后才能宣称该平台通过。发布新版本后,使用实际 Release 附件的 SHA-256 更新 Store,保留所有旧版本与既有平台。 diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md new file mode 100644 index 0000000..87c111b --- /dev/null +++ b/docs/releases/v0.1.0.md @@ -0,0 +1,13 @@ +# Java Support Lite 0.1.0 + +## 中文 + +- 新增 Linux ARM64 和 x64 安装包,兼容现有插件契约 v0 与 Lithe 桌面预览版。 +- 保留已支持平台;安装包在构建时验证,运行时状态保存在用户目录。 + +--- + +## English + +- Add Linux ARM64 and x64 packages for the existing plugin contract v0 and Lithe desktop preview. +- Keep existing platforms; verify release archives at build time and keep runtime state in user directories. diff --git a/io.github.lithe-idea.java-navigation/plugin.json b/io.github.lithe-idea.java-navigation/plugin.json index 8966dd5..f571d1d 100644 --- a/io.github.lithe-idea.java-navigation/plugin.json +++ b/io.github.lithe-idea.java-navigation/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 0, "id": "io.github.lithe-idea.java-navigation", "displayName": "Java Support Lite", - "version": "0.0.3", + "version": "0.1.0", "pluginAPIVersion": 0, "requires": { "languageServers": 0, diff --git a/scripts/package.mjs b/scripts/package.mjs index 21c0a9e..18bbcbf 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -21,6 +21,8 @@ const PACKAGE_DIR = "io.github.lithe-idea.java-navigation"; const ASSET_PREFIX = "java-navigation"; const BINARY = "java-navigation"; const TARGETS = [ + { platform: "linux-arm64", goos: "linux", goarch: "arm64" }, + { platform: "linux-x64", goos: "linux", goarch: "amd64" }, { platform: "darwin-arm64", goos: "darwin", goarch: "arm64" }, { platform: "darwin-x64", goos: "darwin", goarch: "amd64" }, { platform: "windows-x64", goos: "windows", goarch: "amd64" }, @@ -66,7 +68,7 @@ for (const target of targets) { execFileSync( "go", ["build", "-trimpath", "-ldflags", `-s -w -buildid= -X main.version=${manifest.version}`, "-o", join(pkg, "bin", exe), "./cmd/java-navigation"], - { stdio: "inherit", env: { ...process.env, CGO_ENABLED: "0", GOOS: target.goos, GOARCH: target.goarch } }, + { stdio: "inherit", timeout: 120_000, env: { ...process.env, CGO_ENABLED: "0", GOOS: target.goos, GOARCH: target.goarch } }, ); chmodSync(join(pkg, "bin", exe), 0o755); const platformManifest = structuredClone(manifest); @@ -78,7 +80,7 @@ for (const target of targets) { const asset = resolve("dist", name); // -X drops extra file attributes; -y stores a symbolic link as a link so // Lithe refuses it instead of the archive silently carrying its target. - execFileSync("zip", ["-qrXy", asset, PACKAGE_DIR, "-x", "*.DS_Store"], { cwd: staging }); + execFileSync("zip", ["-qrXy", asset, PACKAGE_DIR, "-x", "*.DS_Store"], { cwd: staging, timeout: 30_000 }); const sha256 = createHash("sha256").update(readFileSync(asset)).digest("hex"); console.log(`dist/${name} ${sha256}`); } diff --git a/scripts/package/linux.test.mjs b/scripts/package/linux.test.mjs new file mode 100644 index 0000000..18a39b0 --- /dev/null +++ b/scripts/package/linux.test.mjs @@ -0,0 +1,27 @@ +// Integration checks of the release archives, after scripts/package.mjs. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {readFileSync,mkdtempSync,rmSync,statSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {createHash} from 'node:crypto'; +const id="io.github.lithe-idea.java-navigation"; +const version=JSON.parse(readFileSync(`${id}/plugin.json`)).version; +const read=(archive,path)=>execFileSync('unzip',['-p',archive,`${id}/${path}`],{timeout:10000,maxBuffer:16*1024*1024}); +for (const [arch,machine] of [['arm64',183],['x64',62]]) { + test(`Linux ${arch} archive contains an executable ELF language server`,t=>{ + const archive=resolve(`dist/java-navigation-${version}-linux-${arch}.zip`); + const manifest=JSON.parse(read(archive,'plugin.json')); + assert.equal(manifest.version,version); + const path=manifest.languageServers[0].launch.path; + assert.equal(path,'bin/java-navigation'); + const binary=read(archive,path); + assert.deepEqual([...binary.subarray(0,6)],[127,69,76,70,2,1]); + assert.equal(binary.readUInt16LE(18),machine); + const stage=mkdtempSync(join(tmpdir(),'lithe-linux-archive-')); + t.after(()=>rmSync(stage,{recursive:true,force:true})); + execFileSync('unzip',['-q',archive,'-d',stage],{timeout:10000}); + assert.ok(statSync(join(stage,id,path)).mode & 0o111); + }); +} diff --git a/scripts/package/lsp-client.mjs b/scripts/package/lsp-client.mjs new file mode 100644 index 0000000..fafe9e3 --- /dev/null +++ b/scripts/package/lsp-client.mjs @@ -0,0 +1,43 @@ +// Bounded integration-test client. Each child owns a process group; cleanup +// runs after success, protocol failure, and request timeout. +import {spawn} from 'node:child_process'; +export class Client { + constructor(executable,args=[],options={}){ + this.next=1;this.pending=new Map();this.buffer=Buffer.alloc(0);this.stderr=''; + this.child=spawn(executable,args,{...options,detached:true,stdio:['pipe','pipe','pipe']}); + this.exited=new Promise(resolve=>{this.child.once('exit',(code,signal)=>{this.fail(Error(`Process exited: ${code}/${signal}: ${this.stderr}`));resolve();});this.child.once('error',error=>{this.fail(error);resolve();});}); + this.child.stdin.on('error',error=>this.fail(error)); + this.child.stderr.on('data',bytes=>{this.stderr=(this.stderr+bytes).slice(-65536);}); + this.child.stdout.on('data',bytes=>{ + try{ + this.buffer=Buffer.concat([this.buffer,bytes]);if(this.buffer.length>16*1024*1024)throw Error('Protocol frame exceeds 16 MiB'); + while(true){ + const end=this.buffer.indexOf('\r\n\r\n');if(end<0)return; + const length=Number(/Content-Length: (\d+)/i.exec(this.buffer.subarray(0,end).toString())?.[1]); + if(!Number.isSafeInteger(length)||length>16*1024*1024)throw Error('Invalid protocol length'); + if(this.buffer.length({})):null; + this.send({id:message.id,result}); + }else if(message.id!==undefined){const item=this.pending.get(message.id);if(item){this.pending.delete(message.id);clearTimeout(item.timer);message.error?item.reject(Error(JSON.stringify(message.error))):item.resolve(message.result);}} + } + }catch(error){this.fail(error);} + }); + } + fail(error){this.failure=error;for(const item of this.pending.values()){clearTimeout(item.timer);item.reject(error);}this.pending.clear();} + send(message){const body=JSON.stringify({jsonrpc:'2.0',...message});this.child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);} + notify(method,params){this.send({method,params});} + request(method,params,timeout=60000){ + if(this.failure)return Promise.reject(this.failure); + const id=this.next++;return new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>{this.pending.delete(id);reject(Error(`${method} timed out: ${this.stderr}`));},timeout); + this.pending.set(id,{resolve,reject,timer});this.send({id,method,params}); + }); + } + async close(){ + this.fail(Error('Client closed')); + if(this.child.pid){try{process.kill(-this.child.pid,'SIGKILL');}catch(error){if(error.code!=='ESRCH')throw error;}} + let timer;try{await Promise.race([this.exited,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Process cleanup timed out')),5000);})]);}finally{clearTimeout(timer);} + } +} diff --git a/scripts/package/smoke-linux.mjs b/scripts/package/smoke-linux.mjs new file mode 100644 index 0000000..50e7c6f --- /dev/null +++ b/scripts/package/smoke-linux.mjs @@ -0,0 +1,26 @@ +// Native integration check of the language server users install from the ZIP. +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdtempSync,mkdirSync,writeFileSync,rmSync,readFileSync,readdirSync,statSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {Client} from './lsp-client.mjs'; +assert.equal(process.platform,'linux'); +const archive=resolve(process.argv[2]);const stage=mkdtempSync(join(tmpdir(),'lithe-lite-linux-'));let server; +const inventory=root=>{const entries=[];const walk=path=>{const stat=statSync(path);entries.push([path,stat.mode,stat.isFile()?createHash('sha256').update(readFileSync(path)).digest('hex'):null]);if(stat.isDirectory())for(const name of readdirSync(path).sort())walk(join(path,name));};walk(root);return entries;}; +try{ + execFileSync('unzip',['-q',archive,'-d',stage],{timeout:15000}); + const pkg=join(stage,'io.github.lithe-idea.java-navigation');const before=inventory(pkg); + const workspace=join(stage,'workspace');mkdirSync(workspace);const file=join(workspace,'Hello.java');writeFileSync(file,'class Hello { String value; }\n'); + server=new Client(join(pkg,'bin/java-navigation')); + const initialized=await server.request('initialize',{processId:null,rootUri:pathToFileURL(workspace).href,capabilities:{textDocument:{documentSymbol:{hierarchicalDocumentSymbolSupport:true}}}}); + assert.equal(initialized.capabilities.definitionProvider,true);server.notify('initialized',{}); + server.notify('textDocument/didOpen',{textDocument:{uri:pathToFileURL(file).href,languageId:'java',version:1,text:readFileSync(file,'utf8')}}); + const symbols=await server.request('textDocument/documentSymbol',{textDocument:{uri:pathToFileURL(file).href}}); + assert.ok(symbols.some(symbol=>symbol.name==='Hello')); + await server.request('shutdown',null);server.notify('exit',null);await server.close();server=undefined; + assert.deepEqual(inventory(pkg),before); + console.log(`Verified Linux ${process.arch}: installed LSP initialize, document symbols, shutdown and immutable package`); +}finally{try{if(server)await server.close();}finally{rmSync(stage,{recursive:true,force:true});}}