chore(catalog): initialize production plugin source

This commit is contained in:
2026-09-11 10:58:33 +08:00
commit 557ed8afd0
18 changed files with 596 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Register plugin release
on:
workflow_dispatch:
inputs:
repository:
description: Plugin repository (owner/name)
required: true
type: string
tag:
description: Published release tag
required: true
type: string
jobs:
register:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out catalog from Gitea
env:
CHECKOUT_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
run: |
set -eu
auth_script=$(mktemp)
trap 'rm -f "$auth_script"' EXIT
cat > "$auth_script" <<'AUTH'
#!/bin/sh
case "$1" in
*Username*) printf '%s\n' x-access-token ;;
*) printf '%s\n' "$CHECKOUT_TOKEN" ;;
esac
AUTH
chmod 700 "$auth_script"
GIT_ASKPASS="$auth_script" GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 --branch main "$GITEA_SERVER_URL/trove/curios.git" catalog
- name: Register completed release
working-directory: catalog
run: |
source tools/ci-runtime.sh
node tools/sync-release.mjs --publish
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_TOKEN: ${{ secrets.PLUGIN_RELEASE_TOKEN }}
CATALOG_WRITE_TOKEN: ${{ secrets.GITEA_TOKEN }}
CATALOG_REPOSITORY: ${{ gitea.repository }}
RELEASE_REPOSITORY: ${{ inputs.repository }}
RELEASE_TAG: ${{ inputs.tag }}
NPM_REGISTRY: ${{ vars.NPM_REGISTRY }}
NODE_MIRROR: ${{ vars.NODE_MIRROR }}
+11
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
.env
.env.*
*.curio
.DS_Store
target/
.trove/
/curios/
/shared/
/vendor/
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+27
View File
@@ -0,0 +1,27 @@
# Trove Curios
**让桌面,多一些可能。**
发现专注、轻巧的桌面工具,把常用能力带到手边。
## Harmony Mirror
让鸿蒙,来到桌面。独立投屏窗口,鼠标与键盘直接操作手机。
[探索 Harmony Mirror →](https://git.render.ink/trove-curios/harmony-mirror)
## Harmony Assistant
鸿蒙随行,资料有序。收发完整文件夹,管理应用,让重要资料留在身边。
[探索 Harmony Assistant →](https://git.render.ink/trove-curios/harmony-assistant)
## 带到你的桌面
在 Trove 的「软件源」中添加 Git 仓库:
```text
https://git.render.ink/trove/curios.git
```
选择喜欢的 Curio,安装,即刻使用。
+59
View File
@@ -0,0 +1,59 @@
# 官方软件源的维护与发布
`trove/curios` 保存官方软件源索引、插件仓库登记和发布工具。底座在 `trove/app`;插件源码与安装包分别位于 `trove-curios` 下各仓库的代码和 Releases。
## 仓库职责
- `source/repositories.json`:登记插件 ID 与发布仓库的对应关系。
- `source/index.json`:底座消费的 Source Index v1,保留版本、兼容范围、平台、Release 附件 URL、大小与 SHA-256。
- `tools/prepare-release.mjs`:手动构建后生成发布目录。
- `tools/publish-release.mjs`:可选的命令行上传工具,上传全部附件后发布 Release。
- `tools/notify-release.mjs`:由插件仓库的 Linux workflow 在 Release 发布后触发源收录。
- `tools/sync-release.mjs`:校验登记关系和附件,合并版本,通过文件 SHA 比较后更新远端索引。
- `.gitea/workflows/sync-release.yml`:在 Linux runner 上执行收录。
安装包不进入 Git,所有 `*.curio` 都被忽略。Git 软件源仍指向 `https://git.render.ink/trove/curios.git`。
## 发布协议
插件仓库根目录的 `release.json` 声明仓库、插件 ID。每个仓库只发布一个插件,tag 为 `v<curio.json version>`。
Release 先以草稿创建,上传每个平台的 `.curio` 和 `trove-release.json` 后再发布。描述文件包含仓库、tag、源码 commit、插件 Manifest 摘要,以及附件名、平台、大小和 SHA-256。插件图标以小型图片 Data URL 收录,私有图片也无需额外鉴权。
本机手动生成发布目录,上传全部附件并发布 Release 后,插件的 Linux workflow 使用 `CATALOG_TOKEN` 调用源仓库的 `sync-release.yml`,传入 `repository` 与 `tag`。收录只接受登记仓库及其所属插件 ID,并下载附件校验大小和 SHA-256。
源 workflow 使用 `PLUGIN_RELEASE_TOKEN` 读取私有插件 Release,使用本任务的 `GITEA_TOKEN` 更新自身索引。写入以 Gitea Contents API 的当前文件 SHA 为前提,并发冲突后重新读取、重新合并,不覆盖其他发布。
相同插件 ID、版本和平台的产物不可变。重复收录没有副作用;相同版本的 URL、大小或 SHA-256 发生变化会报错。
## 新增插件
1. 在 `trove-curios` 创建独立仓库,添加源码、`curio.json`、`release.json` 和发布 workflow。
2. 将仓库与 ID 登记到 `source/repositories.json`,授予收录机器人读取该仓库的代码与 Release 的权限。
3. 在对应平台准备构建环境与 Trove CLI,提交源码,运行插件仓库的手动发布脚本并上传 Release。
现有插件仓库中的发布 workflow 可作为模板。发布工具从源仓库检出,统一维护。
## 失败恢复
Release 成功但收录失败时,在插件 Actions 手动运行发布 workflow,填写已有 tag;该 workflow 使用 Linux runner,只重试收录,不重新构建或替换附件。也可在源仓库直接运行收录 workflow。
## 私有源
当前仓库保持私有。底座用本机 Git 凭据读取索引;下载同一 Git 服务器上的私有 Release 附件时,在服务器要求鉴权后查询 Git HTTPS credential helper。账户必须能读取源仓库和对应插件仓库。SSH 密钥仅用于 Git,附件下载还需要本机 HTTPS 凭据。凭据不写进索引或安装包。
## 自动化身份
`trove-publisher` 负责跨仓库通信:可以读取源代码、触发源 Actions,并读取已登记插件的 Release;没有底座仓库访问权限,也不能直接改写源代码。源索引写入由源 workflow 自身的任务令牌完成。
## 大陆网络环境
发布流程直接从本 Gitea 拉取源代码,不依赖 GitHub 的 checkout/setup-node Action,也不在 Linux runner 上构建 macOS 原生产物。
- npm 默认 `https://registry.npmmirror.com`;仓库级 `.npmrc` 也使用该镜像。
- runner 已有 Node.js 22+ 时直接复用;否则从 `https://npmmirror.com/mirrors/node` 下载固定 Node.js 22.23.2,并校验预置的官方 SHA-256。
- 收录只安装锁定版本的 semver,不下载 Corepack/pnpm,不执行 npm 生命周期脚本。
- Actions Variables `NPM_REGISTRY`、`NODE_MIRROR` 可覆盖镜像地址,未设置时采用上述国内地址。
- Linux job 需要 bash、git、curl、tar、xz 和 CA 证书。runner 容器镜像拉取发生在 workflow 执行前;如更换 runner 镜像,应在 VPS 的 Docker 配置或 runner 配置中使用可访问的镜像仓库。
参考:[npmmirror 使用说明](https://npmmirror.com/)、[Gitea Action 下载机制](https://docs.gitea.com/usage/actions/faq/)。
+9
View File
@@ -0,0 +1,9 @@
# 版本与 Release
每个正式插件独立维护 `curio.json` 的版本,并以 `v<version>` tag 发布对应源码快照。原生项目中的版本必须与 Manifest 一致。
安装包存放在该插件仓库的 Gitea Release 附件中。附件按插件 ID、版本、平台命名,不使用 `latest` 之类可变下载地址。`trove-release.json` 记录本次发布的所有产物;源索引由收录 workflow 生成。
相同插件 ID、版本、平台的已发布内容不能覆盖,修复应发布新版本。一个插件在源索引中对应一个条目,版本位于其 `versions` 数组内。
详细流程见 [官方软件源的维护与发布](development.md)。
+14
View File
@@ -0,0 +1,14 @@
{
"name": "trove-catalog",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.23.0",
"scripts": {
"release:sync": "node tools/sync-release.mjs",
"release:publish": "node tools/publish-release.mjs"
},
"dependencies": {
"semver": "7.7.2"
}
}
+24
View File
@@ -0,0 +1,24 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
semver:
specifier: 7.7.2
version: 7.7.2
packages:
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
hasBin: true
snapshots:
semver@7.7.2: {}
+54
View File
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
{
"id": "trove-official",
"name": "Trove 官方软件源",
"repositories": [
{
"repository": "trove-curios/harmony-mirror",
"ids": [
"dev.trove.harmony-mirror"
]
},
{
"repository": "trove-curios/harmony-assistant",
"ids": [
"dev.trove.harmony-assistant"
]
}
]
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Source this file in a Linux job after checking out the catalog.
set -euo pipefail
export npm_config_registry="${NPM_REGISTRY:-https://registry.npmmirror.com}"
export COREPACK_NPM_REGISTRY="$npm_config_registry"
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
if ! command -v node >/dev/null || ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)'; then
case "$(uname -m)" in
x86_64) node_arch=x64; node_sha=d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307 ;;
aarch64|arm64) node_arch=arm64; node_sha=fff4078c5def658577f92c88db7db3bc0072924bfb93fe52c1e744a54e94abb8 ;;
*) echo 'Unsupported Linux runner architecture' >&2; exit 1 ;;
esac
node_archive="node-v22.23.2-linux-${node_arch}.tar.xz"
node_cache="${RUNNER_TEMP:-/tmp}/trove-node-22.23.2-${node_arch}"
mkdir -p "$node_cache"
curl --fail --location --retry 3 --connect-timeout 15 --max-time 180 \
"${NODE_MIRROR:-https://npmmirror.com/mirrors/node}/v22.23.2/$node_archive" -o "$node_cache/$node_archive"
# Hashes are pinned from the official nodejs.org SHASUMS256.txt.
printf '%s %s\n' "$node_sha" "$node_cache/$node_archive" | sha256sum --check --status
tar -xJf "$node_cache/$node_archive" -C "$node_cache" --strip-components=1
export PATH="$node_cache/bin:$PATH"
fi
node --version
# The catalog only needs semver; avoid downloading pnpm/Corepack in each job.
npm install --ignore-scripts --no-audit --no-fund --package-lock=false
+87
View File
@@ -0,0 +1,87 @@
import { execFileSync } from 'node:child_process';
const origin = new URL(process.env.GITEA_SERVER_URL || 'https://git.render.ink');
if (origin.protocol !== 'https:' || origin.username || origin.password) throw new Error('Gitea requires an HTTPS origin without credentials');
export const server = origin.origin;
export const catalogRepository = process.env.CATALOG_REPOSITORY || 'trove/curios';
export function repositoryPath(repository) {
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repository)) throw new Error('Invalid repository name');
return repository.split('/').map(encodeURIComponent).join('/');
}
export async function api(route, { token = process.env.GITEA_TOKEN, method = 'GET', body, missing = false } = {}) {
const auth = authorization(token);
const response = await fetch(`${server}/api/v1${route}`, {
method, redirect: 'error', signal: AbortSignal.timeout(60_000),
headers: { Authorization: auth, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) },
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body),
});
if (missing && response.status === 404) return null;
if (!response.ok) {
const error = new Error(`Gitea ${method} ${route.split('?')[0]} returned HTTP ${response.status}`);
error.status = response.status;
throw error;
}
if (response.status === 204) return null;
return response.json();
}
// Keep credentials on this Gitea origin; release storage may redirect to a CDN.
export async function download(url, token = process.env.GITEA_TOKEN, limit = 512 * 1024 * 1024) {
let current = new URL(url);
for (let redirects = 0; redirects <= 5; redirects++) {
if (current.protocol !== 'https:' || current.username || current.password) throw new Error('Invalid release download URL');
const response = await fetch(current, {
redirect: 'manual', signal: AbortSignal.timeout(120_000),
headers: current.origin === server ? { Authorization: authorization(token) } : {},
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
await response.body?.cancel();
if (!location) throw new Error('Release redirect has no location');
current = new URL(location, current);
continue;
}
if (!response.ok) throw new Error(`Release download returned HTTP ${response.status}`);
const chunks = []; let size = 0;
for await (const chunk of response.body) {
size += chunk.length;
if (size > limit) throw new Error('Release download exceeds declared size limit');
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
throw new Error('Too many release download redirects');
}
let localAuthorization;
function authorization(token) {
if (token) return `token ${token}`;
if (process.env.GITEA_ACTIONS === 'true') throw new Error('Workflow token is missing');
if (!localAuthorization) {
let output;
try {
output = execFileSync('git', ['-c', 'credential.interactive=false', 'credential', 'fill'], {
input: `url=${server}\n\n`, encoding: 'utf8', timeout: 10_000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['pipe', 'pipe', 'ignore'],
});
} catch {
throw new Error('Set GITEA_TOKEN or configure Git HTTPS credentials');
}
const fields = Object.fromEntries(output.trim().split('\n').map(line => {
const separator = line.indexOf('='); return [line.slice(0, separator), line.slice(separator + 1)];
}));
if (!fields.username || !fields.password) throw new Error('Set GITEA_TOKEN or configure Git HTTPS credentials');
localAuthorization = `Basic ${Buffer.from(`${fields.username}:${fields.password}`).toString('base64')}`;
}
return localAuthorization;
}
export async function notifyCatalog(repository, tag) {
await api(`/repos/${repositoryPath(catalogRepository)}/actions/workflows/sync-release.yml/dispatches`, {
token: process.env.CATALOG_TOKEN,
method: 'POST', body: { ref: 'main', inputs: { repository, tag } },
});
console.log(`Requested catalog registration: ${repository}@${tag}`);
}
+7
View File
@@ -0,0 +1,7 @@
import { api, notifyCatalog, repositoryPath } from './gitea.mjs';
const repository = process.env.GITEA_REPOSITORY;
const tag = process.env.RELEASE_TAG;
if (!tag) throw new Error('RELEASE_TAG is required');
const release = await api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`);
if (release.draft || !release.assets.some(asset => asset.name === 'trove-release.json')) throw new Error('A completed release with trove-release.json is required');
await notifyCatalog(repository, tag);
+11
View File
@@ -0,0 +1,11 @@
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import { releasePlan } from './release-plan.mjs';
const { values } = parseArgs({ options: { entries: { type: 'string' }, tag: { type: 'string' } } });
if (!values.entries) throw new Error('--entries is required');
const { files, descriptor } = await releasePlan(values.entries, values.tag);
await rm('dist/release', { recursive: true, force: true });
await mkdir('dist/release', { recursive: true });
for (const [name, bytes] of files) await writeFile(`dist/release/${name}`, bytes);
console.log(`Prepared ${files.size} release files in dist/release/`);
console.log(`Create draft release ${descriptor.tag} at commit ${descriptor.commit}, upload every file, then publish.`);
+40
View File
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { api, download, repositoryPath } from './gitea.mjs';
const config = JSON.parse(await readFile('release.json', 'utf8'));
const descriptorBytes = await readFile('dist/release/trove-release.json');
const descriptor = JSON.parse(descriptorBytes);
const { repository, tag } = descriptor;
if (descriptor.schemaVersion !== 1 || repository !== config.repository) throw new Error('Unexpected release descriptor');
const route = `/repos/${repositoryPath(repository)}`;
const files = new Map();
for (const plugin of descriptor.plugins) {
for (const artifact of plugin.artifacts) {
if (!/^[a-zA-Z0-9._+-]+\.curio$/.test(artifact.asset)) throw new Error('Invalid asset filename');
const bytes = await readFile(`dist/release/${artifact.asset}`);
if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error('Package changed after release preparation');
files.set(artifact.asset, bytes);
}
}
files.set('trove-release.json', descriptorBytes);
let release = await api(`${route}/releases/tags/${encodeURIComponent(tag)}`, { missing: true });
if (!release) release = await api(`${route}/releases`, { method: 'POST', body: {
tag_name: tag, target_commitish: descriptor.commit, name: tag, draft: true,
prerelease: tag.slice(1).split('+')[0].includes('-'),
body: `${config.name} ${tag}\n\n通过 Trove 软件源安装和更新。`,
} });
for (const [name, bytes] of files) {
const existing = release.assets.find(asset => asset.name === name);
if (existing) {
const published = await download(existing.browser_download_url, undefined, bytes.length);
if (!published.equals(bytes)) throw new Error(`Release asset is immutable: ${name}; publish a new version`);
continue;
}
if (!release.draft) throw new Error(`Published release is incomplete: ${name}; publish a new version`);
const form = new FormData(); form.append('attachment', new Blob([bytes]), name);
const asset = await api(`${route}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: form });
release.assets.push(asset);
}
if (release.draft) await api(`${route}/releases/${release.id}`, { method: 'PATCH', body: { draft: false } });
console.log(`Published ${repository}@${tag}`);
console.log('The release workflow will register these packages in the catalog.');
+35
View File
@@ -0,0 +1,35 @@
import { gt } from 'semver';
// Published identity is (Curio ID, version, target). Existing identities are immutable.
export function appendRelease(catalog, manifest, artifact, releasedAt) {
const next = structuredClone(catalog);
let curio = next.curios.find(item => item.id === manifest.id);
if (!curio) {
curio = { id: manifest.id, name: manifest.name, description: manifest.description ?? '', ...(manifest.icon ? { icon: manifest.icon } : {}), versions: [] };
next.curios.push(curio);
}
let version = curio.versions.find(item => item.version === manifest.version);
if (version && version.engine.trove !== manifest.engine.trove) {
throw new Error(`${manifest.id}@${manifest.version}: published engine range cannot change; bump the plugin version`);
}
const existing = version?.artifacts.find(item => item.target === artifact.target);
if (existing) {
for (const key of ['sha256', 'size', 'url']) {
if (existing[key] !== artifact[key]) throw new Error(`${manifest.id}@${manifest.version}/${artifact.target}: published ${key} differs; bump the plugin version`);
}
return next;
}
if (!version) {
const newest = curio.versions.every(item => gt(manifest.version, item.version));
version = { version: manifest.version, engine: structuredClone(manifest.engine), releasedAt, artifacts: [] };
curio.versions.unshift(version);
if (newest) {
curio.name = manifest.name;
curio.description = manifest.description ?? '';
if (manifest.icon) curio.icon = manifest.icon;
}
}
version.artifacts.push(structuredClone(artifact));
next.generatedAt = next.generatedAt > releasedAt ? next.generatedAt : releasedAt;
return next;
}
+45
View File
@@ -0,0 +1,45 @@
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { valid } from 'semver';
export async function releasePlan(entriesFile, tag) {
const config = JSON.parse(await readFile('release.json', 'utf8'));
const repository = config.repository;
if (!tag || !/^[a-zA-Z0-9][a-zA-Z0-9._+-]*$/.test(tag)) throw new Error('A release tag is required');
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const entries = JSON.parse(await readFile(entriesFile, 'utf8')).filter(entry => !entry.fault);
if (!entries.length) throw new Error('No packages to publish');
const plugins = new Map(); const files = new Map();
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
for (const entry of entries) {
const manifest = entry.manifest;
if (!config.ids.includes(manifest.id) || !valid(manifest.version)) throw new Error('Unexpected plugin identity/version');
if (tag !== `v${manifest.version}`) throw new Error('Release tag must match curio.json');
if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(entry.artifact.target)) throw new Error('Unsupported package target');
const bytes = await readFile(entry.file);
if (digest(bytes) !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error('Package differs from build metadata');
const asset = `${manifest.id}-${manifest.version}-${entry.artifact.target}.curio`;
if (files.has(asset)) throw new Error(`Duplicate release asset: ${asset}`);
files.set(asset, bytes);
const key = `${manifest.id}@${manifest.version}`;
if (!plugins.has(key)) {
let icon;
if (manifest.icon) {
const file = path.resolve(entry.directory, manifest.icon);
if (!file.startsWith(path.resolve(entry.directory) + path.sep)) throw new Error('Icon escapes package directory');
const data = await readFile(file);
const type = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[path.extname(file)];
if (type && data.length <= 64 * 1024) icon = `data:${type};base64,${data.toString('base64')}`;
}
plugins.set(key, { id: manifest.id, name: manifest.name, description: manifest.description ?? '', icon,
version: manifest.version, engine: manifest.engine, artifacts: [] });
}
plugins.get(key).artifacts.push({ target: entry.artifact.target, asset, sha256: entry.artifact.sha256, size: bytes.length });
}
const descriptor = Buffer.from(JSON.stringify({ schemaVersion: 1, repository, tag, commit, plugins: [...plugins.values()] }, null, 2) + '\n');
files.set('trove-release.json', descriptor);
return { files, descriptor: JSON.parse(descriptor) };
}
+80
View File
@@ -0,0 +1,80 @@
import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { parseArgs } from 'node:util';
import { valid, validRange, rcompare } from 'semver';
import { api, download, server, catalogRepository, repositoryPath } from './gitea.mjs';
import { appendRelease } from './release-catalog.mjs';
const { values } = parseArgs({ options: {
repository: { type: 'string' }, tag: { type: 'string' }, publish: { type: 'boolean', default: false },
} });
const repository = values.repository || process.env.RELEASE_REPOSITORY;
const tag = values.tag || process.env.RELEASE_TAG;
const config = JSON.parse(await readFile(new URL('../source/repositories.json', import.meta.url), 'utf8'));
const publisher = config.repositories.find(item => item.repository === repository);
if (!publisher || !tag) throw new Error('Repository is not registered in this catalog, or tag is missing');
const release = await api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`);
if (release.draft) throw new Error('Draft releases cannot enter the catalog');
const descriptorAsset = release.assets.find(asset => asset.name === 'trove-release.json');
if (!descriptorAsset) throw new Error('Release has no trove-release.json attachment');
const descriptor = JSON.parse(await download(descriptorAsset.browser_download_url, undefined, 1024 * 1024));
if (descriptor.schemaVersion !== 1 || descriptor.repository !== repository || descriptor.tag !== tag || !Array.isArray(descriptor.plugins) || !descriptor.plugins.length) throw new Error('Invalid release descriptor');
if (!/^[a-f0-9]{40,64}$/.test(descriptor.commit)) throw new Error('Invalid release commit');
const refs = await api(`/repos/${repositoryPath(repository)}/git/refs/tags/${encodeURIComponent(tag)}`);
let object = (Array.isArray(refs) ? refs.find(ref => ref.ref === `refs/tags/${tag}`) : refs)?.object;
for (let depth = 0; object?.type === 'tag' && depth < 5; depth++) {
object = (await api(`/repos/${repositoryPath(repository)}/git/tags/${object.sha}`)).object;
}
if (object?.type !== 'commit' || object.sha !== descriptor.commit) throw new Error('Release tag differs from package source commit');
const additions = []; const identities = new Set();
for (const plugin of descriptor.plugins) {
if (!publisher.ids.includes(plugin.id) || !valid(plugin.version) || !validRange(plugin.engine?.trove) || typeof plugin.name !== 'string' || !plugin.name.trim()) throw new Error('Unexpected plugin identity or compatibility range');
if (tag !== `v${plugin.version}`) throw new Error('Plugin version differs from release tag');
if (!Array.isArray(plugin.artifacts) || !plugin.artifacts.length) throw new Error('Plugin has no artifacts');
for (const artifact of plugin.artifacts) {
if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(artifact.target) || !/^[a-f0-9]{64}$/.test(artifact.sha256) || !Number.isSafeInteger(artifact.size) || artifact.size < 1 || artifact.size > 512 * 1024 * 1024) throw new Error('Invalid artifact metadata');
const identity = `${plugin.id}@${plugin.version}/${artifact.target}`;
if (identities.has(identity)) throw new Error('Duplicate artifact identity');
identities.add(identity);
const asset = release.assets.find(asset => asset.name === artifact.asset);
if (!asset || asset.size !== artifact.size || new URL(asset.browser_download_url).origin !== server) throw new Error('Artifact is not a matching release attachment');
const bytes = await download(asset.browser_download_url, undefined, artifact.size);
if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error(`Release checksum mismatch: ${identity}`);
additions.push({ plugin, artifact: { target: artifact.target, url: asset.browser_download_url, sha256: artifact.sha256, size: artifact.size } });
}
}
function merge(index) {
if (index.schemaVersion !== 1 || index.id !== config.id || !Array.isArray(index.curios)) throw new Error('Unexpected source identity');
for (const { plugin, artifact } of additions) {
index = appendRelease(index, plugin, artifact, release.published_at || release.created_at);
}
index.curios.sort((a, b) => a.id.localeCompare(b.id));
for (const plugin of index.curios) plugin.versions.sort((a, b) => rcompare(a.version, b.version));
return JSON.stringify(index, null, 2) + '\n';
}
if (!values.publish) {
const file = new URL('../source/index.json', import.meta.url);
await writeFile(file, merge(JSON.parse(await readFile(file, 'utf8'))));
console.log(`Updated local catalog with ${repository}@${tag}`);
} else {
// Gitea's contents SHA provides compare-and-swap. On concurrent publications,
// re-read and merge the latest index instead of overwriting another release.
const route = `/repos/${repositoryPath(catalogRepository)}/contents/source/index.json`;
for (let attempt = 0; attempt < 6; attempt++) {
const file = await api(`${route}?ref=main`, { token: process.env.CATALOG_WRITE_TOKEN });
const before = Buffer.from(file.content, 'base64').toString('utf8');
const content = merge(JSON.parse(before));
if (content === before) { console.log('Release already registered'); break; }
try {
await api(route, { token: process.env.CATALOG_WRITE_TOKEN, method: 'PUT', body: {
branch: 'main', sha: file.sha, content: Buffer.from(content).toString('base64'),
message: `chore(catalog): register ${repository}@${tag}`,
} });
console.log(`Registered ${repository}@${tag}`); break;
} catch (error) {
if (![409, 422].includes(error.status) || attempt === 5) throw error;
}
}
}