#!/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 — "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/, поэтому эмулируем его вручную: // тег "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-, см. 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); });