/** * Складывает артефакты 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) * DND_FEED_LARGE_FILE_BYTES — порог "большого" файла для дробления push в squash-режиме (по умолчанию 64 MiB) */ 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'; } function hadOriginUpdatesRemoteRef(work) { try { execFileSync('git', ['show-ref', '--verify', '--quiet', 'refs/remotes/origin/updates'], { cwd: work, stdio: 'ignore', }); return true; } catch { return false; } } function sanitizeRefPart(s) { return s .replace(/[^0-9A-Za-z._-]+/gu, '-') .replace(/^-+|-+$/gu, '') .slice(0, 80); } function pushWithRetries(work, args, label) { 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] ${label} (attempt ${attempt}/${retries})`); execFileSync('git', args, { cwd: work, stdio: 'inherit' }); return; } catch (err) { lastError = err; console.warn(`[sync-update-feed] ${label} failed (attempt ${attempt}/${retries})`); if (attempt < retries) { sleepSyncSeconds(20); } } } throw lastError; } function listFeedFiles(work) { return fs .readdirSync(work) .filter((name) => name !== '.git' && fs.statSync(path.join(work, name)).isFile()) .sort((a, b) => a.localeCompare(b)); } function gitAddFiles(work, files) { if (files.length === 0) return; runGit(['add', '--', ...files], work); } function commitStagedIfAny(work, message) { try { execFileSync('git', ['diff', '--cached', '--quiet'], { cwd: work, stdio: 'ignore' }); return false; } catch { runGit(['commit', '-m', message], work); return true; } } /** * История старых релизов удаляется, но загрузка идёт маленькими pack'ами: * временная ветка получает small files + каждый большой файл отдельным push, * а `updates` передвигается на готовый коммит только в конце. */ function publishSquashedUpdatesBranchStreamed(work, message, tag) { const tempBranch = `updates-upload-${sanitizeRefPart(tag || 'ci')}-${String(Date.now())}`; const files = listFeedFiles(work); const largeFileBytes = Math.max( 8_000_000, Math.min( 256_000_000, Number.parseInt(process.env.DND_FEED_LARGE_FILE_BYTES || '64000000', 10) || 64_000_000, ), ); const smallFiles = []; const largeFiles = []; for (const file of files) { const size = fs.statSync(path.join(work, file)).size; if (size >= largeFileBytes) { largeFiles.push(file); } else { smallFiles.push(file); } } console.warn( `[sync-update-feed] DND_UPDATES_SQUASH_HISTORY=1: переписываем только UPDATES_REPO/updates; ` + `push дробится через временную ветку ${tempBranch} (${smallFiles.length} small, ${largeFiles.length} large)`, ); runGit(['checkout', '--orphan', 'dnd-feed-upload-tmp'], work); execFileSync('git', ['rm', '-r', '--cached', '--ignore-unmatch', '.'], { cwd: work, stdio: 'inherit' }); let pushedTemp = false; gitAddFiles(work, smallFiles); if (commitStagedIfAny(work, `${message} (metadata)`)) { pushWithRetries( work, ['push', '--force', '-u', 'origin', `HEAD:refs/heads/${tempBranch}`], `push temp feed branch ${tempBranch}`, ); pushedTemp = true; } for (const file of largeFiles) { gitAddFiles(work, [file]); if (commitStagedIfAny(work, `${message}: ${file}`)) { pushWithRetries( work, ['push', ...(pushedTemp ? [] : ['--force', '-u']), 'origin', `HEAD:refs/heads/${tempBranch}`], `push temp feed object ${file}`, ); pushedTemp = true; } } if (!pushedTemp) { throw new Error('[sync-update-feed] no feed files staged for streamed squash push'); } const updateArgs = hadOriginUpdatesRemoteRef(work) ? ['push', '--force-with-lease', '-u', 'origin', 'HEAD:updates'] : ['push', '-u', 'origin', 'HEAD:updates']; pushWithRetries(work, updateArgs, 'publish complete feed branch updates'); try { execFileSync('git', ['push', 'origin', `:refs/heads/${tempBranch}`], { cwd: work, stdio: 'inherit' }); } catch { console.warn(`[sync-update-feed] temp branch cleanup failed: ${tempBranch}`); } } 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) { publishSquashedUpdatesBranchStreamed(work, msg, tag); } 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();