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
15 changes: 11 additions & 4 deletions .github/workflows/track-releases.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
name: Track plugin releases

# Scans the GitHub Releases of every listed plugin and opens one pull request
# that adds versions not yet in the index. Plugin authors only need to publish
# a release that follows docs/publishing.md.
# Scans the GitHub Releases of every listed plugin, and of every unlisted
# public `lithe-plugin-*` repository in the Lithe-IDEA organization except
# `lithe-plugin-docs`, then opens one pull request that adds the versions and
# plugins not yet in the index. Plugin authors only need to publish a release
# that follows docs/publishing.md.
on:
schedule:
- cron: "41 */6 * * *"
workflow_dispatch:
inputs:
plugin:
description: "Only track this plugin ID (empty = all)"
description: "Only track this plugin ID, listed or a new organization plugin (empty = all)"
required: false
default: ""

Expand Down Expand Up @@ -58,6 +60,7 @@ jobs:
if: steps.track.outputs.added != '0'
env:
SUMMARY: ${{ steps.track.outputs.summary }}
NEW_PLUGINS: ${{ steps.track.outputs.new_plugins }}
run: |
git config user.name "lithe-store-bot"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
Expand All @@ -69,6 +72,10 @@ jobs:
git commit -m "chore(index): track new plugin releases" -m "$SUMMARY"
git push --force origin "$BRANCH"
body=$(printf '自动追踪到新的插件版本:\n\n%s\n\n已校验 SHA-256 与包内 plugin.json(id、version、pluginAPIVersion、signatureRequirement),README 总览已一并重新生成。维护者请按 docs/maintainers.md 的清单审查后合并。' "$(echo "$SUMMARY" | tr ',' '\n' | sed 's/^ */- /')")
if [ -n "$NEW_PLUGINS" ]; then
# The next scan rebuilds this branch from main, so metadata fixes belong in a follow-up PR.
body="$body$(printf '\n\n首次收录:%s。条目由插件仓库的简介、许可证、topics 和包内 plugin.json 自动生成,没有图标和中文简介,description 沿用仓库简介的语言。请按 docs/maintainers.md 的首次收录清单审查;合并后另提 PR 补充 icon、localizedDescriptions 和 tags,不要直接改这个分支,下一轮扫描会覆盖它。' "$NEW_PLUGINS")"
fi
if gh pr view "$BRANCH" --json state -q .state 2>/dev/null | grep -q OPEN; then
gh pr edit "$BRANCH" --body "$body"
else
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| 我想… | 怎么做 |
| --- | --- |
| 收录自己的插件 | 读 [docs/publishing.md](docs/publishing.md),提 PR 或开“插件收录申请” issue |
| 收录自己的插件 | 读 [docs/publishing.md](docs/publishing.md),提 PR 或开“插件收录申请” issue;Lithe-IDEA 组织的 `lithe-plugin-*` 仓库由 bot 自动收录(见 [首次收录](docs/publishing.md#3-首次收录)) |
| 发布插件新版本 | 在自己仓库发布 Release 即可,bot 会自动开 PR(见 [发布新版本](docs/publishing.md#4-发布新版本)) |
| 修改插件信息 | 提 PR 修改 `lithe-plugin-index.json` 中自己的条目(含 `icon`) |
| 报告有问题的插件 | 开“报告插件问题” issue;安全问题请勾选“安全问题”,维护者会优先处理 |
Expand Down Expand Up @@ -57,7 +57,7 @@ Lithe 会把仓库地址 `https://github.com/Lithe-IDEA/Lithe-store` 解析为
```text
lithe-plugin-index.json # 唯一的数据文件,Lithe 客户端读取它
scripts/validate-index.mjs # 与 Lithe Rust Core 同规则的校验 + 下载包校验
scripts/track-releases.mjs # 追踪已收录插件的新 Release 并生成版本条目
scripts/track-releases.mjs # 追踪已收录插件和 Lithe-IDEA 组织 lithe-plugin-* 仓库的新 Release,生成索引条目
scripts/render-readme.mjs # 根据索引生成 README 的插件总览
scripts/store.test.mjs # 校验脚本的测试(fixtures 复制自插件契约)
assets/ # README 用到的图片(无图标插件的默认图标)
Expand Down
9 changes: 8 additions & 1 deletion docs/maintainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ CI(`Validate index`)已经自动保证:
- [ ] 首次收录:`author` 与仓库所有者对应,名称不冒充其他项目或 Lithe 官方;
- [ ] 插件声明要运行的程序与描述相符(看包内 `plugin.json` 的 `languageServers` 与 `executionProviders`);
- [ ] 描述、标签、图标没有误导内容;
- [ ] 新版本 PR(bot 或作者提交):版本号递增,没有删除旧版本。
- [ ] 新版本 PR(bot 或作者提交):版本号递增,没有删除旧版本;
- [ ] bot 首次收录的插件(PR 描述里的“首次收录”):仓库简介适合作为 `description`,仓库 topics 适合作为 `tags`。
合并后另提 PR 补充 `icon`、`localizedDescriptions`,必要时修改 `description`、`tags`;
不要直接改 `bot/track-releases`,下一轮扫描会从 main 重建这个分支。

## 自动追踪新版本

`Track plugin releases` 每 6 小时运行一次,也可在 Actions 中手动运行(可只指定一个插件 ID)。
它扫描已收录插件的 Release,以及 Lithe-IDEA 组织下尚未收录的公开 `lithe-plugin-*` 仓库
(`lithe-plugin-docs` 不发布插件,固定排除),只收录正式 Release;新插件的条目生成规则见
[发布社区插件的首次收录](publishing.md#3-首次收录)。手动运行时指定的插件 ID 可以是已收录插件,
也可以是组织中尚未收录的插件。
它把结果统一提交到 `bot/track-releases` 分支并开一个 PR,PR 里同时包含索引和重新生成的 README 总览(合并后不需要再等 `Sync README`);新的扫描会覆盖这个分支并更新 PR 描述。

需要的仓库设置:
Expand Down
25 changes: 23 additions & 2 deletions docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ shasum -a 256 zig-support-2.0.0-darwin-arm64.zip

## 3. 首次收录

Lithe-IDEA 组织下名为 `lithe-plugin-*` 的公开仓库(`lithe-plugin-docs` 除外,它不发布插件)
不需要手工提交首次收录 PR。`Track plugin releases` 发现这类仓库还没收录、并且已经有带插件包的
正式 Release 时,会把它连同全部正式版本一起放进 bot PR,条目按以下来源生成:

| 字段 | 来源 |
| --- | --- |
| `id`、`name`、`author` | 最新正式 Release 的包内 `plugin.json`:`id`、`displayName`、`vendor.displayName` |
| `repository`、`homepage` | 仓库地址,`homepage` 为 `<仓库地址>#readme` |
| `description` | GitHub 仓库简介,原样使用 |
| `license` | GitHub 识别出的许可证(SPDX ID) |
| `tags` | 仓库 topics;没有 topics 时省略 |

仓库是私有、已归档或 fork,仓库没有简介,GitHub 识别不出许可证,包内 `plugin.json` 缺字段或 ID 不符合索引规则,
或者 ID 已经被别的条目占用时,自动收录会跳过该仓库,并在工作流日志里写明原因。自动生成的条目
没有 `icon` 和 `localizedDescriptions`,bot PR 合并后另提 PR 补充。和版本追踪一样,只发布过
pre-release 的仓库不会被收录。

其他仓库按下面的步骤收录:

1. Fork 本仓库,在 `lithe-plugin-index.json` 的 `plugins` 中**按 `id` 字母序**插入你的条目:

```json
Expand Down Expand Up @@ -145,7 +164,8 @@ shasum -a 256 zig-support-2.0.0-darwin-arm64.zip
收录后,新版本**不需要你再提 PR**:

1. 在你的仓库递增 `plugin.json` 的 `version`,打包,发布新的正式 Release(资产命名同上)。
2. `Track plugin releases` 工作流每 6 小时扫描一次已收录插件最近 20 个 Release,
2. `Track plugin releases` 工作流每 6 小时扫描一次已收录插件最近 20 个 Release
(Lithe-IDEA 组织尚未收录的 `lithe-plugin-*` 仓库也一起扫描,见 [首次收录](#3-首次收录)),
把新版本写进索引(版本号、`pluginAPIVersion`、`requires` 取自包内 `plugin.json`,
能力表按键排序写入;发布日期取 Release 发布时间,SHA-256 由 CI 计算),
然后开一个 `chore(index): 追踪插件新版本` PR。
Expand All @@ -162,7 +182,8 @@ shasum -a 256 zig-support-2.0.0-darwin-arm64.zip
稳定版用户安装;稳定版是否支持,以 Lithe 的发行说明为准。支持之前,把 GitHub Release 标为
预发布,并手工提交索引条目(LLDB Debugger `0.1.0` 就是这样收录的)。稳定版 Lithe 遇到自己
不支持的 API 等级只会显示“需要更新 Lithe”,不会安装,所以不会装出坏插件。稳定版支持之后,
把 Release 转为正式发布(或发布新版本),自动追踪就会接手后续版本。调试适配器插件的写法见
把 Release 转为正式发布(或发布新版本),自动追踪就会接手后续版本;还没收录的 Lithe-IDEA
组织插件会在这时被自动首次收录。调试适配器插件的写法见
[编写调试适配器插件](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/guides/debug-adapter-plugin.md)。

自动追踪会在日志中说明归档无法收录的原因:没有符合命名的 zip、包内 `plugin.json`
Expand Down
184 changes: 182 additions & 2 deletions scripts/store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { END, START, categoryOf, renderOverview, renderReadme, systemsOf, truncate } from "./render-readme.mjs";
import { addVersion, platformsForAsset, versionFromRelease } from "./track-releases.mjs";
import { checkManifest, formatIndex, inspectArchive, validateEntry, validateIndex, verifyArchives } from "./validate-index.mjs";
import {
addVersion, discoverPlugins, platformsForAsset, unlistedPluginRepositories, versionFromRelease,
} from "./track-releases.mjs";
import {
checkManifest, formatIndex, inspectArchive, readPackageManifests, validateEntry, validateIndex, verifyArchives,
} from "./validate-index.mjs";

// Fixtures are copied from the plugin contract (lithe-plugin-docs contract/v1/fixtures/marketplace)
// so this validator reports the same code as Lithe's Rust Core for each case.
Expand Down Expand Up @@ -207,6 +211,179 @@ test("release tracking preserves API 1/2 releases without adding requires", { ti
}
});

// Organization discovery fixtures: GitHub responses are plain objects, and each
// archive's bytes are its plugin.json text, so no test touches the network or unzip.
const REPOSITORIES_PATH = "orgs/Lithe-IDEA/repos?type=public&per_page=100&page=1";
const ZIG_ID = "io.github.lithe-idea.zig-support";

function organizationRepository(name, overrides = {}) {
return {
name,
full_name: `Lithe-IDEA/${name}`,
html_url: `https://github.com/Lithe-IDEA/${name}`,
private: false,
archived: false,
fork: false,
description: "Zig language support for Lithe",
license: { spdx_id: "Apache-2.0" },
topics: ["language", "zig"],
...overrides,
};
}

function organizationRelease(repository, version, overrides = {}) {
const name = `zig-support-${version}-darwin.zip`;
return {
tag_name: `v${version}`,
draft: false,
prerelease: false,
published_at: "2026-11-01T00:00:00Z",
assets: [{ name, browser_download_url: `${repository.html_url}/releases/download/v${version}/${name}` }],
...overrides,
};
}

function zigManifest(version) {
return {
schemaVersion: 2,
id: ZIG_ID,
displayName: "Zig Support",
version,
pluginAPIVersion: 2,
vendor: { displayName: "Lithe Community", signatureRequirement: "none" },
};
}

function organizationFixture(responses, manifestsByURL) {
const downloads = [];
return {
downloads,
request: async (path) => {
assert.ok(Object.hasOwn(responses, path), `unexpected GitHub request: ${path}`);
return structuredClone(responses[path]);
},
downloadArchive: async (url) => {
assert.ok(Object.hasOwn(manifestsByURL, url), `unexpected download: ${url}`);
downloads.push(url);
return Buffer.from(JSON.stringify(manifestsByURL[url]));
},
readManifests: (bytes, pluginID) => {
const manifest = JSON.parse(bytes.toString());
return { manifests: pluginID === undefined || manifest.id === pluginID ? [manifest] : [], unsafe: [] };
},
};
}

function zigOrganization() {
const repository = organizationRepository("lithe-plugin-zig");
const releases = [
organizationRelease(repository, "1.2.0", { prerelease: true }),
organizationRelease(repository, "1.1.0"),
organizationRelease(repository, "1.0.0"),
];
const manifestsByURL = Object.fromEntries(releases.map((release) =>
[release.assets[0].browser_download_url, zigManifest(release.tag_name.slice(1))]));
const fixture = organizationFixture({
[REPOSITORIES_PATH]: [repository, organizationRepository("lithe-plugin-docs")],
"repos/Lithe-IDEA/lithe-plugin-zig/releases?per_page=20": releases,
}, manifestsByURL);
return { releases, fixture };
}

test("organization discovery considers only active, unlisted lithe-plugin- repositories", () => {
const index = { schemaVersion: 1, plugins: [{ ...validEntry(), repository: "https://github.com/Lithe-IDEA/Lithe-Plugin-Listed" }] };
const names = unlistedPluginRepositories([
organizationRepository("lithe-plugin-zig"),
organizationRepository("lithe-plugin-docs"),
organizationRepository("lithe-plugin-listed"),
organizationRepository("lithe-plugin-old", { archived: true }),
organizationRepository("lithe-plugin-copy", { fork: true }),
organizationRepository("lithe-plugin-secret", { private: true }),
organizationRepository("Lithe-store"),
], index).map((repository) => repository.name);
assert.deepEqual(names, ["lithe-plugin-zig"]);
});

test("organization discovery lists a new plugin with its stable releases", { timeout: 5_000 }, async () => {
const { releases, fixture } = zigOrganization();
const index = { schemaVersion: 1, plugins: [validEntry(), { ...validEntry(), id: "org.example.zig-tools" }] };
const result = await discoverPlugins(index, fixture);

assert.deepEqual(result.skipped, []);
assert.deepEqual(result.newPlugins, [ZIG_ID]);
assert.deepEqual(result.added, [`${ZIG_ID}@1.1.0 (v1.1.0) [new plugin]`, `${ZIG_ID}@1.0.0 (v1.0.0) [new plugin]`]);
assert.deepEqual(index.plugins.map((entry) => entry.id), ["io.example.zig-support", ZIG_ID, "org.example.zig-tools"]);
const { versions, ...metadata } = index.plugins[1];
assert.deepEqual(metadata, {
id: ZIG_ID,
name: "Zig Support",
author: "Lithe Community",
repository: "https://github.com/Lithe-IDEA/lithe-plugin-zig",
homepage: "https://github.com/Lithe-IDEA/lithe-plugin-zig#readme",
description: "Zig language support for Lithe",
tags: ["language", "zig"],
license: "Apache-2.0",
});
assert.deepEqual(versions.map((version) => version.version), ["1.1.0", "1.0.0"]);
assert.deepEqual(validateIndex(index), []);
// The draft and its first version share one download; the pre-release is never fetched.
assert.deepEqual(fixture.downloads, [releases[1], releases[2]].map((release) => release.assets[0].browser_download_url));
});

test("organization discovery keeps only the plugin named by --plugin", { timeout: 5_000 }, async () => {
const other = zigOrganization();
const otherIndex = { schemaVersion: 1, plugins: [validEntry()] };
const skippedRun = await discoverPlugins(otherIndex, { ...other.fixture, onlyPlugin: "io.example.other" });
assert.deepEqual(skippedRun.added, []);
assert.equal(otherIndex.plugins.length, 1);

const named = zigOrganization();
const namedIndex = { schemaVersion: 1, plugins: [validEntry()] };
const namedRun = await discoverPlugins(namedIndex, { ...named.fixture, onlyPlugin: ZIG_ID });
assert.deepEqual(namedRun.newPlugins, [ZIG_ID]);
});

test("organization discovery skips repositories that cannot be listed yet", { timeout: 5_000 }, async () => {
const preview = organizationRepository("lithe-plugin-preview");
const unlicensed = organizationRepository("lithe-plugin-unlicensed", { license: { spdx_id: "NOASSERTION" } });
const undescribed = organizationRepository("lithe-plugin-undescribed", { description: null });
const copy = organizationRepository("lithe-plugin-copy");
const copyRelease = organizationRelease(copy, "1.0.0");
const anonymous = organizationRepository("lithe-plugin-anonymous");
const anonymousRelease = organizationRelease(anonymous, "1.0.0");
const official = organizationRepository("lithe-plugin-official");
const officialRelease = organizationRelease(official, "1.0.0");
const fixture = organizationFixture({
[REPOSITORIES_PATH]: [preview, unlicensed, undescribed, copy, anonymous, official],
"repos/Lithe-IDEA/lithe-plugin-preview/releases?per_page=20": [organizationRelease(preview, "0.1.0", { prerelease: true })],
"repos/Lithe-IDEA/lithe-plugin-unlicensed/releases?per_page=20": [organizationRelease(unlicensed, "1.0.0")],
"repos/Lithe-IDEA/lithe-plugin-undescribed/releases?per_page=20": [organizationRelease(undescribed, "1.0.0")],
"repos/Lithe-IDEA/lithe-plugin-copy/releases?per_page=20": [copyRelease],
"repos/Lithe-IDEA/lithe-plugin-anonymous/releases?per_page=20": [anonymousRelease],
"repos/Lithe-IDEA/lithe-plugin-official/releases?per_page=20": [officialRelease],
}, {
[copyRelease.assets[0].browser_download_url]: { ...zigManifest("1.0.0"), id: "io.example.zig-support" },
[anonymousRelease.assets[0].browser_download_url]: { ...zigManifest("1.0.0"), id: "io.github.lithe-idea.anonymous", vendor: { signatureRequirement: "none" } },
[officialRelease.assets[0].browser_download_url]: { ...zigManifest("1.0.0"), id: "dev.lithe.official" },
});
const index = { schemaVersion: 1, plugins: [validEntry()] };
const result = await discoverPlugins(index, fixture);

assert.deepEqual(result.added, []);
assert.deepEqual(result.newPlugins, []);
assert.equal(index.plugins.length, 1);
const log = result.skipped.join("\n");
assert.match(log, /lithe-plugin-preview: no stable release/);
assert.match(log, /lithe-plugin-unlicensed: GitHub detects no license/);
assert.match(log, /lithe-plugin-undescribed: the repository has no description/);
assert.match(log, /lithe-plugin-copy: io\.example\.zig-support is already listed for https:\/\/github\.com\/example\/lithe-zig/);
// A drafted entry that the index would reject never gains a version, so it is not listed.
assert.match(log, /io\.github\.lithe-idea\.anonymous.*author/);
assert.match(log, /dev\.lithe\.official.*officialNamespace/);
// Metadata problems are found before any archive is downloaded.
assert.deepEqual(fixture.downloads, [copyRelease, anonymousRelease, officialRelease].map((release) => release.assets[0].browser_download_url));
});

test("archive verification rechecks changed manifest expectations for the same URL and hash", { timeout: 5_000 }, async () => {
const entry = capabilityEntry();
const manifest = manifestFor(entry);
Expand Down Expand Up @@ -322,6 +499,9 @@ test("archives must hold one matching community plugin.json", () => {
/signatureRequirement/,
);
assert.match(inspectArchive(build({ ...manifest, id: "io.other" }), entry, version).join(), /exactly one plugin\.json/);
// Without an ID (an unlisted plugin) every manifest that declares one is returned.
assert.deepEqual(readPackageManifests(build({ ...manifest, id: "io.other" })).manifests.map((found) => found.id), ["io.other"]);
assert.deepEqual(readPackageManifests(build({ version: "1.0.0" })).manifests, []);
} finally {
rmSync(workDir, { recursive: true, force: true });
}
Expand Down
Loading
Loading