/** * Складывает артефакты electron-builder (win + mac + linux) в публичный репозиторий, * ветка `updates`, чтобы generic URL …/raw/branch/updates/ указывал на актуальные latest*.yml и установщики. * * Копирование **merge**: существующие файлы в ветке (другие ОС) не удаляются — обновляются только * те имена, которые пришли из переданных каталогов артефактов. * * Переменные окружения: * DND_UPDATES_SERVER — https://git.example.com (без слэша в конце) * UPDATES_REPO — owner/repo (публичный репозиторий) * DND_UPDATES_PUSH_TOKEN — PAT с правом push в UPDATES_REPO * ARTIFACT_WIN — каталог с файлами Windows (можно пустой / отсутствует — пропуск) * ARTIFACT_MAC — каталог с файлами macOS * ARTIFACT_LINUX — каталог с файлами Linux (AppImage и т.д.) * GIT_COMMIT_TAG — опционально, для сообщения коммита * DND_GIT_PUSH_RETRIES — опционально, число попыток git push (1–5, по умолчанию 3) * DND_UPDATES_CLONE_DEPTH — опционально, глубина shallow clone (2–200, по умолчанию 40), чтобы merge с remote был надёжнее * DND_FEED_TMP_ROOT — каталог для временного клона feed (по умолчанию GITHUB_WORKSPACE / TMPDIR / os.tmpdir); не используйте узкий /tmp на раннере */ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ALLOWED_EXT = new Set(['.yml', '.yaml', '.exe', '.blockmap', '.zip', '.dmg', '.pkg', '.appimage']); function mustEnv(name) { const v = process.env[name]?.trim(); if (!v) throw new Error(`Missing env ${name}`); return v; } function optionalDir(name) { const v = process.env[name]?.trim(); return v && v.length > 0 ? v : ''; } /** Куда класть временный клон: workspace раннера предпочтительнее, чем /tmp (место под AppImage). */ function feedTempRoot() { const a = process.env.DND_FEED_TMP_ROOT?.trim() || process.env.GITHUB_WORKSPACE?.trim() || process.env.GITEA_WORKSPACE?.trim() || process.env.TMPDIR?.trim() || os.tmpdir(); return a; } /** Перенос (rename) без дублирования байтов на одном томе; иначе copy + unlink исходника (освобождение места). */ function moveOrCopyArtifactFile(src, dest) { if (fs.existsSync(dest)) { fs.unlinkSync(dest); } try { fs.renameSync(src, dest); return; } catch (e) { const code = /** @type {NodeJS.ErrnoException} */ (e).code; if (code !== 'EXDEV' && code !== 'EINVAL') throw e; } fs.copyFileSync(src, dest); fs.unlinkSync(src); } function moveOrCopyFlatReleaseFiles(fromDir, toDir) { if (!fromDir || !fs.existsSync(fromDir)) { console.warn(`[sync-update-feed] skip missing dir: ${fromDir || '(empty)'}`); return 0; } let n = 0; for (const name of fs.readdirSync(fromDir)) { const src = path.join(fromDir, name); if (!fs.statSync(src).isFile()) continue; const ext = path.extname(name).toLowerCase(); if (!ALLOWED_EXT.has(ext)) continue; moveOrCopyArtifactFile(src, path.join(toDir, name)); n += 1; } return n; } function runGit(args, cwd) { execFileSync('git', args, { cwd, stdio: 'inherit' }); } /** Большие AppImage + exe в одном push: без этого Git по умолчанию может оборвать HTTPS (postBuffer / stall). */ function configureGitHttpForLargePush(cwd) { // 2 GiB — достаточно для пачки артефактов; на старых Git при необходимости поднять на сервере лимиты nginx/Gitea. runGit(['config', 'http.postBuffer', '2147483648'], cwd); runGit(['config', 'http.lowSpeedLimit', '0'], cwd); runGit(['config', 'http.lowSpeedTime', '0'], cwd); } function sleepSyncSeconds(seconds) { try { execFileSync('sleep', [String(seconds)], { stdio: 'ignore' }); } catch { const end = Date.now() + seconds * 1000; while (Date.now() < end) { /* fallback без утилиты sleep */ } } } function mergeOriginUpdates(work) { const fetchRetries = Math.max( 1, Math.min(6, Number.parseInt(process.env.DND_GIT_FETCH_RETRIES || '4', 10) || 4), ); let fetchOk = false; let lastFetchErr; for (let i = 0; i < fetchRetries; i += 1) { try { runGit(['fetch', 'origin', 'updates'], work); fetchOk = true; break; } catch (err) { lastFetchErr = err; console.warn(`[sync-update-feed] git fetch failed (attempt ${i + 1}/${fetchRetries})`); if (i < fetchRetries - 1) { sleepSyncSeconds(15); } } } if (!fetchOk) { throw lastFetchErr ?? new Error('git fetch origin updates failed'); } runGit(['merge', '--no-edit', 'origin/updates'], work); } function pushUpdatesBranch(work) { const retries = Math.max(1, Math.min(5, Number.parseInt(process.env.DND_GIT_PUSH_RETRIES || '3', 10) || 3)); let lastError; for (let attempt = 1; attempt <= retries; attempt += 1) { try { console.log(`[sync-update-feed] merge origin/updates before push (attempt ${attempt}/${retries})`); mergeOriginUpdates(work); runGit(['push', '-u', 'origin', 'updates'], work); return; } catch (err) { lastError = err; console.warn(`[sync-update-feed] merge/push failed (attempt ${attempt}/${retries})`); if (attempt < retries) { sleepSyncSeconds(20); } } } throw lastError; } function main() { const server = mustEnv('DND_UPDATES_SERVER').replace(/\/+$/u, ''); const updatesRepo = mustEnv('UPDATES_REPO'); const token = mustEnv('DND_UPDATES_PUSH_TOKEN'); const winDir = optionalDir('ARTIFACT_WIN'); const macDir = optionalDir('ARTIFACT_MAC'); const linuxDir = optionalDir('ARTIFACT_LINUX'); const u = new URL(server); const host = u.host; const cloneUrl = `https://oauth2:${encodeURIComponent(token)}@${host}/${updatesRepo}.git`; const tmpRoot = feedTempRoot(); fs.mkdirSync(tmpRoot, { recursive: true }); const tmp = fs.mkdtempSync(path.join(tmpRoot, 'dnd-feed-')); const work = path.join(tmp, 'repo'); const cloneDepth = Math.max( 2, Math.min(200, Number.parseInt(process.env.DND_UPDATES_CLONE_DEPTH || '40', 10) || 40), ); try { execFileSync('git', ['clone', `--depth=${String(cloneDepth)}`, '-b', 'updates', cloneUrl, work], { stdio: 'inherit', }); } catch { execFileSync('git', ['clone', `--depth=${String(cloneDepth)}`, cloneUrl, work], { stdio: 'inherit' }); runGit(['checkout', '-B', 'updates'], work); } runGit(['config', 'user.email', 'ci@gitea-actions.local'], work); runGit(['config', 'user.name', 'gitea-actions'], work); configureGitHttpForLargePush(work); try { mergeOriginUpdates(work); } catch { console.warn('[sync-update-feed] initial merge skipped (новая ветка или сеть/TLS — продолжаем)'); } const artifactDirs = [...new Set([winDir, macDir, linuxDir].filter(Boolean))]; let copied = 0; for (const d of artifactDirs) { copied += moveOrCopyFlatReleaseFiles(d, work); } if (copied === 0) { throw new Error( '[sync-update-feed] no release files copied (check ARTIFACT_WIN / ARTIFACT_MAC / ARTIFACT_LINUX)', ); } const tag = process.env.GIT_COMMIT_TAG?.trim() || 'ci'; runGit(['add', '-A'], work); const st = execFileSync('git', ['status', '--porcelain'], { cwd: work }).toString().trim(); if (st) { runGit(['commit', '-m', `update feed ${tag}`], work); pushUpdatesBranch(work); } else { console.warn('[sync-update-feed] nothing to commit (identical artifacts?)'); } fs.rmSync(tmp, { recursive: true, force: true }); console.log(`[sync-update-feed] done (${String(copied)} file(s) into feed repo)`); } main();