Files
DndGamePlayer/scripts/sync-update-feed.mjs
T
Ivan Fontosh 8fa8467db7
Release / release (push) Successful in 7m10s
fix(ci): stream update feed squash push
Split the squashed updates feed upload through a temporary branch so large installers are pushed in smaller packs, then publish the completed updates branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 14:14:54 +08:00

448 lines
17 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
* 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();