Files
DndGamePlayer/scripts/sync-update-feed.mjs
T
Ivan Fontosh 1c6f06b278
Release / release (push) Failing after 4m50s
fix(ci): sync feed fetch retries with gc, HTTP/1.1, disk check; fail on TLS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 13:10:37 +08:00

304 lines
12 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.
/**
* Складывает артефакты 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 (2200, по умолчанию 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
*/
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 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 cloneDepth = 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) {
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();