chore(catalog): initialize production plugin source
This commit is contained in:
@@ -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
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
*.curio
|
||||
.DS_Store
|
||||
target/
|
||||
.trove/
|
||||
/curios/
|
||||
/shared/
|
||||
/vendor/
|
||||
@@ -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,安装,即刻使用。
|
||||
@@ -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/)。
|
||||
@@ -0,0 +1,9 @@
|
||||
# 版本与 Release
|
||||
|
||||
每个正式插件独立维护 `curio.json` 的版本,并以 `v<version>` tag 发布对应源码快照。原生项目中的版本必须与 Manifest 一致。
|
||||
|
||||
安装包存放在该插件仓库的 Gitea Release 附件中。附件按插件 ID、版本、平台命名,不使用 `latest` 之类可变下载地址。`trove-release.json` 记录本次发布的所有产物;源索引由收录 workflow 生成。
|
||||
|
||||
相同插件 ID、版本、平台的已发布内容不能覆盖,修复应发布新版本。一个插件在源索引中对应一个条目,版本位于其 `versions` 数组内。
|
||||
|
||||
详细流程见 [官方软件源的维护与发布](development.md)。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+24
@@ -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: {}
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.`);
|
||||
@@ -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.');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user