diff --git a/.github/workflows/track-releases.yml b/.github/workflows/track-releases.yml index 25eb3d3..17f97d0 100644 --- a/.github/workflows/track-releases.yml +++ b/.github/workflows/track-releases.yml @@ -2,9 +2,18 @@ name: Track plugin releases # 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. +# `lithe-plugin-docs`, and adds the versions and plugins not yet in the index. +# Plugin authors only need to publish a release that follows docs/publishing.md. +# +# The scan runs once per scope, and each scope keeps its own pull request: +# +# - organization: plugins whose repository belongs to the Lithe-IDEA organization, +# including first listings. After the archives are verified, the workflow +# merges the pull request itself by fast-forwarding main with the +# STORE_BOT_DEPLOY_KEY deploy key, the only bot credential that the +# "Only owners merge main" ruleset lets through. +# - external: every other plugin. The pull request waits for a maintainer +# review, and this scope never receives the deploy key. on: schedule: - cron: "41 */6 * * *" @@ -25,10 +34,29 @@ concurrency: jobs: track: + name: track (${{ matrix.scope }}) + # The workflow writes main's index and may push to main, so it must run the + # workflow and scripts from main. A manual run from another branch does nothing. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 30 + strategy: + # One scope at a time, organization first, so the external branch starts + # from the main that the organization scope may have just updated. + max-parallel: 1 + fail-fast: false + matrix: + include: + - scope: organization + branch: bot/track-organization-releases + title: "chore(index): 追踪 Lithe-IDEA 组织插件新版本" + - scope: external + branch: bot/track-releases + title: "chore(index): 追踪组织外插件新版本" env: - BRANCH: bot/track-releases + SCOPE: ${{ matrix.scope }} + BRANCH: ${{ matrix.branch }} + TITLE: ${{ matrix.title }} # A PR opened with GITHUB_TOKEN does not trigger other workflows. Set the # STORE_BOT_TOKEN secret (fine-grained PAT: contents + pull requests) so # `Validate index` runs on the bot PR as well. @@ -36,7 +64,13 @@ jobs: steps: - uses: actions/checkout@v7 with: + # The latest main, not the commit that triggered the run: the external + # scope must start from the main that the organization scope just updated. + ref: main token: ${{ secrets.STORE_BOT_TOKEN || github.token }} + # Git then pushes over SSH with the deploy key. The external scope + # gets an empty key and keeps using the token, which cannot write main. + ssh-key: ${{ matrix.scope == 'organization' && secrets.STORE_BOT_DEPLOY_KEY || '' }} - uses: actions/setup-node@v7 with: node-version: 22 @@ -48,15 +82,25 @@ jobs: PLUGIN: ${{ inputs.plugin }} run: | cp lithe-plugin-index.json "$RUNNER_TEMP/base-index.json" - args="" - if [ -n "$PLUGIN" ]; then args="--plugin $PLUGIN"; fi - node scripts/track-releases.mjs lithe-plugin-index.json $args + args=(--scope "$SCOPE") + if [ -n "$PLUGIN" ]; then args+=(--plugin "$PLUGIN"); fi + node scripts/track-releases.mjs lithe-plugin-index.json "${args[@]}" + + - name: Close the obsolete pull request + if: steps.track.outputs.added == '0' && inputs.plugin == '' + run: | + number=$(gh pr list --head "$BRANCH" --state open --json number -q '.[0].number') + if [ -n "$number" ]; then + gh pr close "$number" --delete-branch \ + --comment "最新一轮追踪在这个范围内没有发现 main 尚未收录的版本,这个 PR 不再需要。" + fi - name: Verify the new archives if: steps.track.outputs.added != '0' run: node scripts/validate-index.mjs lithe-plugin-index.json --verify-archives --base "$RUNNER_TEMP/base-index.json" - name: Open or update the pull request + id: pr if: steps.track.outputs.added != '0' env: SUMMARY: ${{ steps.track.outputs.summary }} @@ -69,16 +113,57 @@ jobs: # pull request. Render the overview here so one merge covers both. node scripts/render-readme.mjs git add lithe-plugin-index.json README.md - git commit -m "chore(index): track new plugin releases" -m "$SUMMARY" + git commit -m "chore(index): track $SCOPE 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/^ */- /')") + + list=$(echo "$SUMMARY" | tr ',' '\n' | sed 's/^ */- /') + checks="已校验 SHA-256 与包内 plugin.json(id、version、pluginAPIVersion、signatureRequirement),README 总览已一并重新生成。" + if [ "$SCOPE" = organization ]; then + review="这些插件的仓库属于 Lithe-IDEA 组织,工作流会在校验通过后直接合入 main。如果这个 PR 仍处于打开状态,说明自动合入没有成功(原因见工作流日志),请维护者按 docs/maintainers.md 的清单审查后合并。" + else + review="这些插件来自 Lithe-IDEA 组织以外的仓库,维护者请按 docs/maintainers.md 的清单审查后合并。" + fi + body=$(printf '自动追踪到新的插件版本:\n\n%s\n\n%s%s' "$list" "$checks" "$review") 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")" + body="$body$(printf '\n\n首次收录:%s。条目由插件仓库的简介、许可证、topics 和包内 plugin.json 自动生成,没有图标和中文简介,description 沿用仓库简介的语言。合并后请另提 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" + + number=$(gh pr list --head "$BRANCH" --state open --json number -q '.[0].number') + if [ -n "$number" ]; then + gh pr edit "$number" --title "$TITLE" --body "$body" else - gh pr create --base main --head "$BRANCH" --title "chore(index): 追踪插件新版本" --body "$body" --label new-version || \ - gh pr create --base main --head "$BRANCH" --title "chore(index): 追踪插件新版本" --body "$body" + gh pr create --base main --head "$BRANCH" --title "$TITLE" --body "$body" --label new-version || \ + gh pr create --base main --head "$BRANCH" --title "$TITLE" --body "$body" + number=$(gh pr list --head "$BRANCH" --state open --json number -q '.[0].number') + fi + echo "number=$number" >> "$GITHUB_OUTPUT" + + - name: Merge the organization pull request + if: matrix.scope == 'organization' && steps.track.outputs.added != '0' + env: + NUMBER: ${{ steps.pr.outputs.number }} + HAS_DEPLOY_KEY: ${{ secrets.STORE_BOT_DEPLOY_KEY != '' }} + run: | + if [ "$HAS_DEPLOY_KEY" != true ]; then + echo "::warning::STORE_BOT_DEPLOY_KEY is not set, so pull request #$NUMBER stays open for a maintainer to merge." + exit 0 + fi + # Fast-forward main to the verified commit. Without --force, git refuses + # the push if main moved after the checkout; the next run then rebuilds + # the branch from the new main. + if ! git push origin "HEAD:main"; then + echo "::warning::Could not fast-forward main, so pull request #$NUMBER stays open for a maintainer to merge." + exit 0 fi + # GitHub marks the pull request merged once its head commit is on main. + # Delete the branch only after that, or the pull request would show as + # closed instead of merged. + for _ in $(seq 1 12); do + if [ "$(gh pr view "$NUMBER" --json state -q .state)" = MERGED ]; then + git push origin --delete "$BRANCH" + exit 0 + fi + sleep 5 + done + echo "::warning::main contains pull request #$NUMBER, but GitHub has not marked it merged after 60 seconds. Delete $BRANCH once it has." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1ac50d..fb475d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ | 我想… | 怎么做 | | --- | --- | | 收录自己的插件 | 读 [docs/publishing.md](docs/publishing.md),提 PR 或开“插件收录申请” issue;Lithe-IDEA 组织的 `lithe-plugin-*` 仓库由 bot 自动收录(见 [首次收录](docs/publishing.md#3-首次收录)) | -| 发布插件新版本 | 在自己仓库发布 Release 即可,bot 会自动开 PR(见 [发布新版本](docs/publishing.md#4-发布新版本)) | +| 发布插件新版本 | 在自己仓库发布 Release 即可,bot 会自动开 PR;Lithe-IDEA 组织插件校验通过后自动合入(见 [发布新版本](docs/publishing.md#4-发布新版本)) | | 修改插件信息 | 提 PR 修改 `lithe-plugin-index.json` 中自己的条目(含 `icon`) | | 报告有问题的插件 | 开“报告插件问题” issue;安全问题请勾选“安全问题”,维护者会优先处理 | | 改进脚本、CI 或文档 | 直接提 PR,并运行 `node --test scripts/store.test.mjs` | @@ -57,11 +57,11 @@ 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 # 追踪已收录插件和 Lithe-IDEA 组织 lithe-plugin-* 仓库的新 Release,生成索引条目 +scripts/track-releases.mjs # 追踪已收录插件和 Lithe-IDEA 组织 lithe-plugin-* 仓库的新 Release,生成索引条目;--scope 区分组织插件与组织外插件 scripts/render-readme.mjs # 根据索引生成 README 的插件总览 scripts/store.test.mjs # 校验脚本的测试(fixtures 复制自插件契约) assets/ # README 用到的图片(无图标插件的默认图标) -.github/workflows/ # validate(PR/推送/每周)、track-releases(每 6 小时)、sync-readme(main 更新后开 PR 同步 README) +.github/workflows/ # validate(PR/推送/每周)、track-releases(每 6 小时,组织插件自动合入、组织外插件开 PR 审查)、sync-readme(main 更新后开 PR 同步 README) ``` ## 本地校验 diff --git a/docs/maintainers.md b/docs/maintainers.md index 7fc59a9..1e07990 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -9,34 +9,66 @@ CI(`Validate index`)已经自动保证: - 包内恰好一个匹配的 `plugin.json`,`version`、`pluginAPIVersion` 与索引一致, `vendor.signatureRequirement` 为 `"none"`,没有不安全的路径。 -维护者人工确认: +维护者人工确认(Lithe-IDEA 组织插件的 bot PR 由工作流自动合入,不走这份清单,见[自动追踪新版本](#自动追踪新版本)): - [ ] `repository` 是公开仓库,有 README、许可证,看得出是该作者在维护; - [ ] 首次收录:`author` 与仓库所有者对应,名称不冒充其他项目或 Lithe 官方; - [ ] 插件声明要运行的程序与描述相符(看包内 `plugin.json` 的 `languageServers` 与 `executionProviders`); - [ ] 描述、标签、图标没有误导内容; -- [ ] 新版本 PR(bot 或作者提交):版本号递增,没有删除旧版本; -- [ ] bot 首次收录的插件(PR 描述里的“首次收录”):仓库简介适合作为 `description`,仓库 topics 适合作为 `tags`。 - 合并后另提 PR 补充 `icon`、`localizedDescriptions`,必要时修改 `description`、`tags`; - 不要直接改 `bot/track-releases`,下一轮扫描会从 main 重建这个分支。 +- [ ] 新版本 PR(bot 或作者提交):版本号递增,没有删除旧版本。 + +bot 首次收录的组织插件(组织 PR 描述里的“首次收录”)合入后,维护者补做两件事: + +- 检查仓库简介是否适合作为 `description`,仓库 topics 是否适合作为 `tags`,不合适就另提 PR 修改; +- 另提 PR 补充 `icon`、`localizedDescriptions`。不要直接改 `bot/track-organization-releases`,下一轮扫描会从 main 重建这个分支。 ## 自动追踪新版本 -`Track plugin releases` 每 6 小时运行一次,也可在 Actions 中手动运行(可只指定一个插件 ID)。 +`Track plugin releases` 每 6 小时运行一次,也可在 Actions 中对 `main` 手动运行(可只指定一个插件 ID;从其他分支启动不会执行)。 它扫描已收录插件的 Release,以及 Lithe-IDEA 组织下尚未收录的公开 `lithe-plugin-*` 仓库 (`lithe-plugin-docs` 不发布插件,固定排除),只收录正式 Release;新插件的条目生成规则见 [发布社区插件的首次收录](publishing.md#3-首次收录)。手动运行时指定的插件 ID 可以是已收录插件, 也可以是组织中尚未收录的插件。 -它把结果统一提交到 `bot/track-releases` 分支并开一个 PR,PR 里同时包含索引和重新生成的 README 总览(合并后不需要再等 `Sync README`);新的扫描会覆盖这个分支并更新 PR 描述。 + +扫描按插件仓库的所有者分两轮,先组织插件、后组织外插件,每轮维护一个 bot PR: + +| | Lithe-IDEA 组织插件 | 组织外插件 | +| --- | --- | --- | +| 范围 | `repository` 属于 Lithe-IDEA 组织的已收录插件,以及新发现的组织 `lithe-plugin-*` 仓库 | 其他已收录插件;不会被自动首次收录 | +| 分支 | `bot/track-organization-releases` | `bot/track-releases` | +| 合入 | 校验通过后由工作流直接合入 main,不经人工审查 | 维护者按[审查清单](#审查清单)审查后合并 | + +两个 PR 都同时包含索引和重新生成的 README 总览,合入后不需要再等 `Sync README`。每轮扫描从 main 重建 +对应分支并更新 PR;某一轮没有发现 main 尚未收录的版本时,这一轮遗留的 PR 会被自动关闭 +(只指定一个插件的手动运行不会关闭 PR)。 + +组织插件的自动合入分三步: + +1. 开 PR 之前运行 `validate-index.mjs --verify-archives`,与 `Validate index` 对新增归档的检查相同; +2. 用 `STORE_BOT_DEPLOY_KEY` 把 main 快进到 PR 的提交,GitHub 据此把 PR 标为已合并; +3. PR 标为已合并后删除分支。 + +main 在扫描期间被别人更新时快进会失败;没有配置 `STORE_BOT_DEPLOY_KEY` 时工作流不尝试合入。 +这两种情况都只在工作流里给出警告,PR 保持打开,由维护者按审查清单审查后合并,下一轮扫描也会基于新的 main 重建它。 +删除 deploy key 或 secret 即可停止自动合入。 + +信任边界:在任一 Lithe-IDEA 组织插件仓库能发布 Release 的人,以及能在组织里创建公开 +`lithe-plugin-*` 仓库的人,都可以不经 Store 维护者审查把插件发布到 Lithe Store。 +授予组织成员身份或插件仓库写权限时按这个标准判断。 需要的仓库设置: 1. **Settings → Actions → General → Workflow permissions**:勾选 “Allow GitHub Actions to create and approve pull requests”。 -2. 可选但推荐:创建 `STORE_BOT_TOKEN` secret(fine-grained PAT,仅本仓库, - Contents 与 Pull requests 读写)。使用默认 `GITHUB_TOKEN` 开的 PR 不会触发 - `Validate index`;工作流已在开 PR 前完成同样的校验,但合并前需要手动运行一次检查才能满足分支保护。 -3. 分支保护:`main` 要求 `Validate index / validate` 通过,要求 CODEOWNERS 审查。 +2. **Settings → Deploy keys**:一把允许写入的 deploy key + (`lithe-store-bot (track organization releases)`),私钥保存为 `STORE_BOT_DEPLOY_KEY` secret。 + 只有组织插件那一轮的 checkout 会拿到这把 key,组织外那一轮仍用 `GITHUB_TOKEN`,写不了 main。 +3. **Settings → Rules → Rulesets**:规则集 “Only owners merge main” 限制 `main` 的更新、删除和强制推送, + 只有组织管理员和 deploy key 可以绕过。GitHub 不允许把 GitHub Actions 加入仓库规则集的绕过名单, + 所以工作流用 deploy key 写 main。这项绕过对仓库的所有 deploy key 生效,新增其他 deploy key 前先评估。 +4. 可选:创建 `STORE_BOT_TOKEN` secret(fine-grained PAT,仅本仓库,Contents 与 Pull requests 读写)。 + 使用默认 `GITHUB_TOKEN` 开的 PR 不会触发 `Validate index`;工作流已在开 PR 前完成同样的校验。 + deploy key 推送到 main 会正常触发 main 上的 `Validate index` 和 `Sync README`。 ## 自动同步 README 总览 @@ -46,8 +78,8 @@ README 的插件总览由 `scripts/render-readme.mjs` 根据索引生成。`main 合并这个 PR 后工作流会再运行一次,发现总览已经一致就结束。总览已经一致时,上一轮没人合并的同步 PR 会被自动关闭。 仓库设置与追踪新版本相同(“Allow GitHub Actions to create and approve pull requests”, -可选的 `STORE_BOT_TOKEN`)。没有配置 `STORE_BOT_TOKEN` 时,这个 PR 也不会触发 `Validate index`, -需要手动运行一次检查才能满足分支保护。同步 PR 只应该包含 `README.md`;如果还改了别的文件,不要合并。 +可选的 `STORE_BOT_TOKEN`),同步 PR 不使用 deploy key,始终由维护者合并。没有配置 `STORE_BOT_TOKEN` 时, +这个 PR 也不会触发 `Validate index`。同步 PR 只应该包含 `README.md`;如果还改了别的文件,不要合并。 ## 下架与撤回 diff --git a/docs/publishing.md b/docs/publishing.md index 4b51b2c..48c7018 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -79,7 +79,8 @@ shasum -a 256 zig-support-2.0.0-darwin-arm64.zip Lithe-IDEA 组织下名为 `lithe-plugin-*` 的公开仓库(`lithe-plugin-docs` 除外,它不发布插件) 不需要手工提交首次收录 PR。`Track plugin releases` 发现这类仓库还没收录、并且已经有带插件包的 -正式 Release 时,会把它连同全部正式版本一起放进 bot PR,条目按以下来源生成: +正式 Release 时,会把它连同全部正式版本一起放进组织插件的 bot PR,校验通过后自动合入 main, +条目按以下来源生成: | 字段 | 来源 | | --- | --- | @@ -91,7 +92,7 @@ Lithe-IDEA 组织下名为 `lithe-plugin-*` 的公开仓库(`lithe-plugin-docs 仓库是私有、已归档或 fork,仓库没有简介,GitHub 识别不出许可证,包内 `plugin.json` 缺字段或 ID 不符合索引规则, 或者 ID 已经被别的条目占用时,自动收录会跳过该仓库,并在工作流日志里写明原因。自动生成的条目 -没有 `icon` 和 `localizedDescriptions`,bot PR 合并后另提 PR 补充。和版本追踪一样,只发布过 +没有 `icon` 和 `localizedDescriptions`,自动合入后由维护者另提 PR 补充。和版本追踪一样,只发布过 pre-release 的仓库不会被收录。 其他仓库按下面的步骤收录: @@ -168,8 +169,9 @@ pre-release 的仓库不会被收录。 (Lithe-IDEA 组织尚未收录的 `lithe-plugin-*` 仓库也一起扫描,见 [首次收录](#3-首次收录)), 把新版本写进索引(版本号、`pluginAPIVersion`、`requires` 取自包内 `plugin.json`, 能力表按键排序写入;发布日期取 Release 发布时间,SHA-256 由 CI 计算), - 然后开一个 `chore(index): 追踪插件新版本` PR。 -3. 维护者审查合并后,Lithe 用户刷新插件市场就能看到更新。 + 然后按插件仓库的所有者开 PR:Lithe-IDEA 组织插件进 `chore(index): 追踪 Lithe-IDEA 组织插件新版本`, + 校验通过后由工作流直接合入;其他插件进 `chore(index): 追踪组织外插件新版本`。 +3. 组织外插件的 PR 由维护者审查后合并。新版本进入 main 后,Lithe 用户刷新插件市场就能看到更新。 想更快上线,可以在 issue 里请维护者手动触发工作流,或者自己提 PR 在 `versions` 里加一项 (保持新版本在前)。 diff --git a/scripts/store.test.mjs b/scripts/store.test.mjs index b8f8de3..e5ea983 100644 --- a/scripts/store.test.mjs +++ b/scripts/store.test.mjs @@ -7,7 +7,8 @@ import { join } from "node:path"; import { test } from "node:test"; import { END, START, categoryOf, renderOverview, renderReadme, systemsOf, truncate } from "./render-readme.mjs"; import { - addVersion, discoverPlugins, platformsForAsset, unlistedPluginRepositories, versionFromRelease, + addVersion, discoverPlugins, isOrganizationEntry, platformsForAsset, trackIndex, unlistedPluginRepositories, + versionFromRelease, } from "./track-releases.mjs"; import { checkManifest, formatIndex, inspectArchive, readPackageManifests, validateEntry, validateIndex, verifyArchives, @@ -384,6 +385,63 @@ test("organization discovery skips repositories that cannot be listed yet", { ti assert.deepEqual(fixture.downloads, [copyRelease, anonymousRelease, officialRelease].map((release) => release.assets[0].browser_download_url)); }); +test("plugins whose repository belongs to Lithe-IDEA are organization plugins, in any letter case", () => { + assert.equal(isOrganizationEntry({ repository: "https://github.com/lithe-idea/lithe-plugin-zig" }), true); + assert.equal(isOrganizationEntry({ repository: "https://github.com/Lithe-IDEA/lithe-plugin-zig/" }), true); + assert.equal(isOrganizationEntry(validEntry()), false); +}); + +// A listed organization plugin and the external fixture plugin, each with one new release. +function scopedTracking() { + const zigRepository = organizationRepository("lithe-plugin-zig"); + const organizationPluginRelease = organizationRelease(zigRepository, "1.1.0"); + const externalRelease = organizationRelease({ html_url: "https://github.com/example/lithe-zig" }, "9.0.0"); + const organizationEntry = { + id: ZIG_ID, + name: "Zig Support", + author: "Lithe Community", + repository: zigRepository.html_url, + description: "Zig language support for Lithe", + license: "Apache-2.0", + versions: [], + }; + const index = { schemaVersion: 1, plugins: [validEntry(), organizationEntry] }; + const fixture = organizationFixture({ + [REPOSITORIES_PATH]: [zigRepository], + "repos/Lithe-IDEA/lithe-plugin-zig/releases?per_page=20": [organizationPluginRelease], + "repos/example/lithe-zig/releases?per_page=20": [externalRelease], + }, { + [organizationPluginRelease.assets[0].browser_download_url]: zigManifest("1.1.0"), + [externalRelease.assets[0].browser_download_url]: { ...zigManifest("9.0.0"), id: "io.example.zig-support" }, + }); + const requests = []; + const request = (path) => { + requests.push(path); + return fixture.request(path); + }; + return { index, archiveIO: { ...fixture, request }, requests }; +} + +test("scoped tracking separates organization plugins from external plugins", { timeout: 5_000 }, async () => { + const organization = scopedTracking(); + const organizationRun = await trackIndex(organization.index, { ...organization.archiveIO, scope: "organization" }); + assert.deepEqual(organizationRun.skipped, []); + assert.deepEqual(organizationRun.added, [`${ZIG_ID}@1.1.0 (v1.1.0)`]); + assert.deepEqual(organization.requests, ["repos/Lithe-IDEA/lithe-plugin-zig/releases?per_page=20", REPOSITORIES_PATH]); + + const external = scopedTracking(); + const externalRun = await trackIndex(external.index, { ...external.archiveIO, scope: "external" }); + assert.deepEqual(externalRun.skipped, []); + assert.deepEqual(externalRun.added, ["io.example.zig-support@9.0.0 (v9.0.0)"]); + assert.deepEqual(externalRun.newPlugins, []); + // The external pass never scans the organization, so it cannot list a plugin for the first time. + assert.deepEqual(external.requests, ["repos/example/lithe-zig/releases?per_page=20"]); + + const unscoped = scopedTracking(); + const unscopedRun = await trackIndex(unscoped.index, unscoped.archiveIO); + assert.deepEqual(unscopedRun.added, ["io.example.zig-support@9.0.0 (v9.0.0)", `${ZIG_ID}@1.1.0 (v1.1.0)`]); +}); + test("archive verification rechecks changed manifest expectations for the same URL and hash", { timeout: 5_000 }, async () => { const entry = capabilityEntry(); const manifest = manifestFor(entry); diff --git a/scripts/track-releases.mjs b/scripts/track-releases.mjs index 40af35d..f5377eb 100644 --- a/scripts/track-releases.mjs +++ b/scripts/track-releases.mjs @@ -1,7 +1,10 @@ #!/usr/bin/env node // Finds GitHub Releases of listed plugins that are not in the index yet and // adds them as new versions. The scheduled `track-releases` workflow runs this -// and opens a pull request; maintainers review it like any other index change. +// once per scope and opens one pull request per scope: `organization` covers +// plugins whose repository belongs to the Lithe-IDEA organization and is merged +// by the workflow after the archives are verified; `external` covers every other +// plugin and waits for a maintainer review. // // Public repositories of the Lithe-IDEA organization whose names start with // `lithe-plugin-` are tracked before they are listed: once such a repository @@ -16,8 +19,9 @@ // for one archive that serves every architecture of that OS; // - every archive agrees on plugin.json `version`, `pluginAPIVersion`, and `requires`. // -// Usage: node scripts/track-releases.mjs [index.json] [--plugin ] [--dry-run] -// `--plugin` accepts the ID of a listed plugin or of an unlisted organization plugin. +// Usage: node scripts/track-releases.mjs [index.json] [--scope organization|external] [--plugin ] [--dry-run] +// Without `--scope` every plugin is tracked. `--plugin` accepts the ID of a +// listed plugin or of an unlisted organization plugin. // GITHUB_TOKEN is used for API requests when set. import { createHash } from "node:crypto"; @@ -29,6 +33,7 @@ export const DISCOVERY_OWNER = "Lithe-IDEA"; export const DISCOVERY_PREFIX = "lithe-plugin-"; // Organization repositories that carry the prefix but do not publish a plugin. export const DISCOVERY_EXCLUDED = new Set(["lithe-plugin-docs"]); +export const SCOPES = ["organization", "external"]; export const KNOWN_ARCHITECTURES = { darwin: ["arm64", "x64"], windows: ["x64", "arm64"], linux: ["x64", "arm64"] }; const ASSET_PLATFORM = /-(darwin|windows|linux)(?:-(arm64|x64))?\.zip$/; @@ -62,6 +67,18 @@ function repositoryPath(repository) { return `${owner}/${repo}`; } +/** True when the plugin's repository belongs to the Lithe-IDEA organization. */ +export function isOrganizationEntry(entry) { + const [owner] = repositoryPath(entry.repository).split("/"); + return owner.toLowerCase() === DISCOVERY_OWNER.toLowerCase(); +} + +/** Whether a run limited to `scope` tracks the entry. A run without a scope tracks every entry. */ +export function inScope(entry, scope) { + if (scope === undefined) return true; + return isOrganizationEntry(entry) === (scope === "organization"); +} + async function github(path) { const headers = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }; if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; @@ -286,32 +303,54 @@ export async function discoverPlugins(index, { return { added, skipped, newPlugins }; } -async function main(argv) { - const pluginFlag = argv.indexOf("--plugin"); - const onlyPlugin = pluginFlag >= 0 ? argv[pluginFlag + 1] : undefined; - const dryRun = argv.includes("--dry-run"); - const indexPath = argv.find((arg, position) => !arg.startsWith("--") && argv[position - 1] !== "--plugin") - ?? "lithe-plugin-index.json"; - const index = JSON.parse(readFileSync(indexPath, "utf8")); - +/** + * Tracks the listed plugins of `scope` and, unless the scope is `external`, + * discovers unlisted organization plugins. Discovered repositories belong to + * the Lithe-IDEA organization, so they always fall in the `organization` scope. + */ +export async function trackIndex(index, { + scope, onlyPlugin, request = github, downloadArchive, readManifests, +} = {}) { const added = []; const skipped = []; for (const entry of index.plugins) { - if (onlyPlugin && entry.id !== onlyPlugin) continue; + if (!inScope(entry, scope) || (onlyPlugin && entry.id !== onlyPlugin)) continue; let releases; try { - releases = await github(`repos/${repositoryPath(entry.repository)}/releases?per_page=20`); + releases = await request(`repos/${repositoryPath(entry.repository)}/releases?per_page=20`); } catch (error) { skipped.push(`${entry.id}: ${error.message}`); continue; } - const result = await trackEntry(entry, releases); + const result = await trackEntry(entry, releases, { downloadArchive, readManifests }); added.push(...result.added); skipped.push(...result.skipped); } - const discovered = await discoverPlugins(index, { onlyPlugin }); - added.push(...discovered.added); - skipped.push(...discovered.skipped); + if (scope === "external") return { added, skipped, newPlugins: [] }; + + const discovered = await discoverPlugins(index, { onlyPlugin, request, downloadArchive, readManifests }); + return { added: [...added, ...discovered.added], skipped: [...skipped, ...discovered.skipped], newPlugins: discovered.newPlugins }; +} + +function flagValue(argv, flag) { + const position = argv.indexOf(flag); + return position >= 0 ? argv[position + 1] : undefined; +} + +async function main(argv) { + const onlyPlugin = flagValue(argv, "--plugin"); + const scope = flagValue(argv, "--scope"); + if (scope !== undefined && !SCOPES.includes(scope)) { + console.error(`--scope must be one of: ${SCOPES.join(", ")}`); + return 2; + } + const dryRun = argv.includes("--dry-run"); + const valueFlags = new Set(["--plugin", "--scope"]); + const indexPath = argv.find((arg, position) => !arg.startsWith("--") && !valueFlags.has(argv[position - 1])) + ?? "lithe-plugin-index.json"; + const index = JSON.parse(readFileSync(indexPath, "utf8")); + + const { added, skipped, newPlugins } = await trackIndex(index, { scope, onlyPlugin }); const problems = validateIndex(index); for (const line of added) console.log(`+ ${line}`); @@ -323,7 +362,7 @@ async function main(argv) { if (!dryRun && added.length > 0) writeFileSync(indexPath, formatIndex(index)); if (process.env.GITHUB_OUTPUT) { const summary = added.join(", "); - const output = `added=${added.length}\nsummary=${summary}\nnew_plugins=${discovered.newPlugins.join(", ")}\n`; + const output = `added=${added.length}\nsummary=${summary}\nnew_plugins=${newPlugins.join(", ")}\n`; writeFileSync(process.env.GITHUB_OUTPUT, output, { flag: "a" }); } return 0;