/** * Складывает артефакты 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 на раннере * DND_GIT_FETCH_RETRIES — число попыток git fetch (1–6, по умолчанию 6); между попытками — git gc --prune=now (освобождение после обрыва TLS/unpack) * DND_FEED_SKIP_DISK_CHECK — если "1", не проверять свободное место на томе DND_FEED_TMP_ROOT перед clone * DND_UPDATES_SQUASH_HISTORY — если "1", после каждого релиза ветка updates в UPDATES_REPO — один корневой коммит (orphan + force-with-lease push), история не копится (меньше места на раннере и на сервере после GC) */ import { execFileSync, spawnSync } 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']); /** Нет ветки updates на сервере — не путать с обрывом TLS. */ const GIT_FETCH_MISSING_UPDATES_REF_RE = /couldn't find remote ref updates\b/i; /** Одинаковые флаги для clone (до появления .git/config) и согласованы с configureGitHttp. */ function gitHttpInlineFlags() { return [ '-c', 'http.version=HTTP/1.1', '-c', 'http.postBuffer=2147483648', '-c', 'http.lowSpeedLimit=0', '-c', 'http.lowSpeedTime=0', ]; } function estimateArtifactBytes(winDir, macDir, linuxDir) { const dirs = [...new Set([winDir, macDir, linuxDir].filter(Boolean))]; let total = 0; for (const fromDir of dirs) { if (!fromDir || !fs.existsSync(fromDir)) continue; for (const name of fs.readdirSync(fromDir)) { const p = path.join(fromDir, name); if (!fs.statSync(p).isFile()) continue; const ext = path.extname(name).toLowerCase(); if (!ALLOWED_EXT.has(ext)) continue; total += fs.statSync(p).size; } } return total; } /** Свободные байты на томе, где лежит tmpRoot; при ошибке — «достаточно». */ function freeBytesOnVolume(tmpRoot) { try { const s = fs.statfsSync(tmpRoot); const bavail = typeof s.bavail === 'bigint' ? Number(s.bavail) : s.bavail; const bsize = typeof s.bsize === 'bigint' ? Number(s.bsize) : s.bsize; if (!Number.isFinite(bavail) || !Number.isFinite(bsize)) return Number.MAX_SAFE_INTEGER; return bavail * bsize; } catch { return Number.MAX_SAFE_INTEGER; } } function assertFeedDiskHeadroom(tmpRoot, artifactBytes) { if (process.env.DND_FEED_SKIP_DISK_CHECK?.trim() === '1') return; const avail = freeBytesOnVolume(tmpRoot); const minBytes = Math.max(2_000_000_000, Math.round(artifactBytes * 2.5) + 1_000_000_000); if (avail < minBytes) { throw new Error( `[sync-update-feed] мало места на томе «${tmpRoot}»: свободно ~${(avail / 1e9).toFixed(2)} GiB, ` + `нужно примерно ≥ ${(minBytes / 1e9).toFixed(2)} GiB (артефакты ~${(artifactBytes / 1e9).toFixed(2)} GiB + shallow clone/pack + git add). ` + `Освободите диск на раннере, увеличьте том или задайте DND_FEED_TMP_ROOT на другой диск. Либо DND_FEED_SKIP_DISK_CHECK=1 (не рекомендуется).`, ); } } function tryGitGcPrune(work) { try { execFileSync('git', ['gc', '--prune=now'], { cwd: work, stdio: 'ignore' }); } catch { /* после оборванного fetch часть pack/tmp остаётся — gc освобождает; игнорируем сбой gc */ } } 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.version', 'HTTP/1.1'], cwd); 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 feedSquashSingleCommitEnabled() { return process.env.DND_UPDATES_SQUASH_HISTORY?.trim() === '1'; } /** После merge и add: одна линия без истории (только репо UPDATES_REPO / ветка updates). */ function squashUpdatesBranchToSingleCommit(work, message) { const orphan = 'dnd-feed-squash-tmp'; runGit(['checkout', '--orphan', orphan], work); runGit(['add', '-A'], work); runGit(['commit', '-m', message], work); runGit(['branch', '-D', 'updates'], work); runGit(['branch', '-m', 'updates'], work); } function hadOriginUpdatesRemoteRef(work) { try { execFileSync('git', ['show-ref', '--verify', '--quiet', 'refs/remotes/origin/updates'], { cwd: work, stdio: 'ignore', }); return true; } catch { return false; } } /** После squash не делаем merge перед push — вернёт старую историю с remote. */ function pushUpdatesBranchAfterSquash(work) { const retries = Math.max(1, Math.min(5, Number.parseInt(process.env.DND_GIT_PUSH_RETRIES || '3', 10) || 3)); const args = hadOriginUpdatesRemoteRef(work) ? ['push', '--force-with-lease', '-u', 'origin', 'updates'] : ['push', '-u', 'origin', 'updates']; let lastError; for (let attempt = 1; attempt <= retries; attempt += 1) { try { console.log(`[sync-update-feed] push updates (squash, attempt ${attempt}/${retries})`); execFileSync('git', args, { cwd: work, stdio: 'inherit' }); return; } catch (err) { lastError = err; console.warn(`[sync-update-feed] push failed (attempt ${attempt}/${retries})`); if (attempt < retries) { sleepSyncSeconds(20); } } } throw lastError; } function mergeOriginUpdates(work) { const fetchRetries = Math.max( 1, Math.min(8, Number.parseInt(process.env.DND_GIT_FETCH_RETRIES || '6', 10) || 6), ); let fetchOk = false; /** @type {Error | undefined} */ let lastFetchErr; for (let i = 0; i < fetchRetries; i += 1) { const r = spawnSync('git', ['fetch', 'origin', 'updates'], { cwd: work, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, }); if (r.status === 0) { fetchOk = true; break; } const errText = `${r.stderr || ''}\n${r.stdout || ''}`; if (GIT_FETCH_MISSING_UPDATES_REF_RE.test(errText)) { console.warn('[sync-update-feed] ветки updates нет на remote — пропуск merge (первый push feed)'); return; } lastFetchErr = new Error(errText.trim() || `git fetch exited with status ${String(r.status)}`); console.warn(`[sync-update-feed] git fetch failed (attempt ${i + 1}/${fetchRetries})`); if (r.stderr?.trim()) { console.warn(r.stderr.trim()); } tryGitGcPrune(work); if (i < fetchRetries - 1) { sleepSyncSeconds(18); } } 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 }); assertFeedDiskHeadroom(tmpRoot, estimateArtifactBytes(winDir, macDir, linuxDir)); const tmp = fs.mkdtempSync(path.join(tmpRoot, 'dnd-feed-')); const work = path.join(tmp, 'repo'); const squash = feedSquashSingleCommitEnabled(); const cloneDepth = squash ? Math.max(1, Math.min(200, Number.parseInt(process.env.DND_UPDATES_CLONE_DEPTH || '8', 10) || 8)) : Math.max(2, Math.min(200, Number.parseInt(process.env.DND_UPDATES_CLONE_DEPTH || '40', 10) || 40)); const gh = gitHttpInlineFlags(); try { execFileSync('git', [...gh, 'clone', `--depth=${String(cloneDepth)}`, '-b', 'updates', cloneUrl, work], { stdio: 'inherit', }); } catch { execFileSync('git', [...gh, '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); mergeOriginUpdates(work); 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) { const msg = `update feed ${tag}`; if (squash) { console.warn( '[sync-update-feed] DND_UPDATES_SQUASH_HISTORY=1: одна линия в UPDATES_REPO (orphan + force-with-lease при наличии origin/updates)', ); squashUpdatesBranchToSingleCommit(work, msg); pushUpdatesBranchAfterSquash(work); } else { runGit(['commit', '-m', msg], 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();