Files
netrunner-app/scripts/gitea-publish-release.mjs
T
nineap 948f15d44a Gitea-native механизм публикации релиза (без tauri-action)
tauri-apps/tauri-action жёстко ходит в api.github.com для создания релиза и
загрузки апдейтер-артефактов — не настраиваемый эндпоинт, для Gitea не
годится ни при какой замене секретов. Вместо неё:

- scripts/gitea-publish-release.mjs — публикует релиз через прямые вызовы
  Gitea Release API: собирает latest.json (формат манифеста Tauri v2
  updater) из .sig-файлов подписанных бандлов, создаёт версионный релиз
  (тег = версия, для истории) и пересоздаёт релиз "latest" — у Gitea нет
  built-in алиаса /releases/latest/download/<file> как у GitHub, поэтому
  тег "latest" удаляется и создаётся заново на каждом релизе с теми же
  ассетами, чтобы URL апдейтера был стабильным. Оба релиза
  удаляются-и-пересоздаются — идемпотентно к повторному прогону тега.
- .gitea/workflows/release.yml — job "build" (та же матрица платформ) вызывает
  `tauri build` напрямую (подпись через TAURI_SIGNING_PRIVATE_KEY читает сам
  Tauri CLI, без разницы кто его вызывает), собирает пары <файл>+.sig в
  dist-bundle/ и грузит артефактом bundle-<updater_key>; job "publish"
  скачивает все артефакты и вызывает скрипт выше.
- tauri.conf.json: добавлен второй updater-эндпоинт (Gitea, плейсхолдер
  CHANGE_ME_GITEA_SERVER — заполнить реальным адресом) рядом с
  существующим GitHub — апдейтер пробует оба по порядку.

GitHub-версия пайплайна не тронута, продолжает работать через tauri-action
как раньше. Не проверено вживую на реальном Gitea/Windows/macOS раннере —
нет доступа в этой среде.

Нужные секреты в Gitea: GITEA_SERVER_URL, GITEA_TOKEN (личный токен с
правом записи в репозиторий), плюс уже существующие
TAURI_SIGNING_PRIVATE_KEY(_PASSWORD).
2026-07-09 14:36:08 +07:00

168 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Публикует Tauri-релиз в Gitea напрямую через Release API — вместо
// tauri-apps/tauri-action, который жёстко ходит в api.github.com (не
// настраиваемый эндпоинт, для Gitea не годится). Используется только в
// .gitea/workflows/release.yml, job "publish".
//
// Схема:
// 1. Job "build" (матрица платформ) собирает бандлы через `tauri build`
// напрямую (не через tauri-action) и складывает каждую подписанную пару
// <файл>+<файл>.sig в dist-bundle/, которая грузится как build-артефакт
// с именем bundle-<updater_key> (updater_key — "darwin-aarch64" и т.п.,
// см. matrix в workflow).
// 2. Этот скрипт (job "publish") скачивает все артефакты, находит пары
// <файл>+.sig, собирает latest.json (формат манифеста апдейтера Tauri v2,
// см. https://v2.tauri.app/plugin/updater/) и публикует:
// - версионный релиз (тег = версия, для истории/changelog в Gitea UI);
// - релиз "latest" — Gitea, в отличие от GitHub, не имеет built-in алиаса
// /releases/latest/download/<file>, поэтому эмулируем его вручную:
// тег "latest" удаляется и пересоздаётся на каждом релизе с теми же
// ассетами, что и версионный — так у апдейтера в tauri.conf.json
// стабильный URL, не привязанный к конкретной версии.
//
// Оба релиза удаляются-и-пересоздаются при каждом запуске (idempotent —
// повторный прогон того же тега не падает на "уже существует").
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, basename } from "node:path";
const GITEA_SERVER_URL = requireEnv("GITEA_SERVER_URL").replace(/\/$/, "");
const GITEA_TOKEN = requireEnv("GITEA_TOKEN");
const REPO = requireEnv("GITEA_REPO"); // "owner/name"
const VERSION = requireEnv("RELEASE_VERSION"); // напр. "v1.2.3"
const ARTIFACTS_DIR = process.argv[2] || "artifacts";
function requireEnv(name) {
const v = process.env[name];
if (!v) throw new Error(`Переменная окружения ${name} обязательна`);
return v;
}
function apiUrl(path) {
return `${GITEA_SERVER_URL}/api/v1/repos/${REPO}${path}`;
}
async function giteaFetch(path, options = {}) {
const res = await fetch(apiUrl(path), {
...options,
headers: {
Authorization: `token ${GITEA_TOKEN}`,
...(options.headers || {}),
},
});
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`Gitea API ${path} -> ${res.status}: ${text}`);
}
return res;
}
/** Рекурсивно находит все пары <файл>+<файл>.sig под dir. */
function collectSigPairs(dir) {
const pairs = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
pairs.push(...collectSigPairs(full));
continue;
}
if (entry.name.endsWith(".sig")) {
pairs.push({ sigPath: full, artifactPath: full.slice(0, -4), dir });
}
}
return pairs;
}
/** Удаляет (если есть) и заново создаёт релиз на теге — гарантирует idempotent-повтор. */
async function ensureRelease(tag) {
const existing = await giteaFetch(`/releases/tags/${tag}`);
if (existing.status === 200) {
const { id } = await existing.json();
await giteaFetch(`/releases/${id}`, { method: "DELETE" });
}
// Релиз и git-тег в Gitea — разные сущности; удаление релиза не удаляет тег.
await giteaFetch(`/tags/${tag}`, { method: "DELETE" });
const created = await giteaFetch(`/releases`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag_name: tag,
name: tag,
draft: false,
prerelease: false,
target_commitish: process.env.GITHUB_SHA || "main",
}),
});
if (!created.ok) {
throw new Error(`Не удалось создать релиз ${tag}: HTTP ${created.status}`);
}
return created.json();
}
async function uploadAsset(releaseId, filePath, fileName) {
const data = readFileSync(filePath);
const form = new FormData();
form.append("attachment", new Blob([data]), fileName);
const res = await giteaFetch(
`/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
{ method: "POST", body: form },
);
if (!res.ok) {
throw new Error(`Не удалось загрузить ${fileName}: HTTP ${res.status}`);
}
return res.json();
}
async function main() {
const pairs = collectSigPairs(ARTIFACTS_DIR);
if (pairs.length === 0) {
throw new Error(
`Не найдено ни одной пары <файл>+.sig в ${ARTIFACTS_DIR} — сборка не создала подписанные updater-бандлы?`,
);
}
const versionedRelease = await ensureRelease(VERSION);
const latestRelease = await ensureRelease("latest");
const platforms = {};
for (const { sigPath, artifactPath, dir } of pairs) {
const signature = readFileSync(sigPath, "utf-8").trim();
const fileName = basename(artifactPath);
// Папка артефакта matrix-job'а называется bundle-<updater_key>, см. workflow.
const updaterKey = basename(dir).replace(/^bundle-/, "");
console.log(`→ ${updaterKey}: ${fileName}`);
await uploadAsset(versionedRelease.id, artifactPath, fileName);
await uploadAsset(latestRelease.id, artifactPath, fileName);
platforms[updaterKey] = {
signature,
// Всегда через тег "latest" — стабильный URL независимо от версии.
url: `${GITEA_SERVER_URL}/${REPO}/releases/download/latest/${fileName}`,
};
}
const manifest = {
version: VERSION.replace(/^v/, ""),
notes: `Netrunner VPN ${VERSION}`,
pub_date: new Date().toISOString(),
platforms,
};
const manifestPath = join(ARTIFACTS_DIR, "latest.json");
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
await uploadAsset(versionedRelease.id, manifestPath, "latest.json");
await uploadAsset(latestRelease.id, manifestPath, "latest.json");
console.log("✅ Опубликовано:\n" + JSON.stringify(manifest, null, 2));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});