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
18 changes: 15 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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"
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
10 changes: 10 additions & 0 deletions docs/feature-matrix.md
Original file line number Diff line number Diff line change
@@ -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/` 和现有集成测试 |

跨架构本地包检查通过不等于原生工作流验收。既有平台验证记录保持不变。
11 changes: 11 additions & 0 deletions docs/linux-packaging.md
Original file line number Diff line number Diff line change
@@ -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 <zip>` 在对应原生 Linux runner 上启动包内程序,完成 LSP initialize、文档符号与 shutdown,验证安装目录文件内容与权限不变,并清理进程组和临时工作区。

## 资源与验证边界

所有下载、解压、编译与归档都属于本插件仓库的构建阶段。`dist/` 为输出包;`.artifacts/`、`.cache/`、`target/` 或工具自身缓存为本地构建状态,不跨工作树共享可变状态。校验过的下载以固定来源与摘要识别,提取结果不作为可信下载源。运行时只读取已安装插件,缓存、日志、索引和工作区状态归用户可写目录。Linux 不使用 macOS 代码签名或 Sparkle;本次不改变 macOS 包签名与更新基线。

原生 ARM64 和 x64 验证由 `.github/workflows/ci.yml` 执行;本机的交叉编译与归档检查不能替代原生运行结果。只有目标平台 CI 成功后才能宣称该平台通过。发布新版本后,使用实际 Release 附件的 SHA-256 更新 Store,保留所有旧版本与既有平台。
13 changes: 13 additions & 0 deletions docs/releases/v0.1.0.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion io.github.lithe-idea.java-navigation/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions scripts/package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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);
Expand All @@ -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}`);
}
27 changes: 27 additions & 0 deletions scripts/package/linux.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
}
43 changes: 43 additions & 0 deletions scripts/package/lsp-client.mjs
Original file line number Diff line number Diff line change
@@ -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<end+4+length)return;
const message=JSON.parse(this.buffer.subarray(end+4,end+4+length));this.buffer=this.buffer.subarray(end+4+length);
if(message.method && message.id!==undefined){
const result=message.method==='workspace/configuration'?(message.params?.items??[]).map(()=>({})):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);}
}
}
26 changes: 26 additions & 0 deletions scripts/package/smoke-linux.mjs
Original file line number Diff line number Diff line change
@@ -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});}}
Loading