3877a6f2a6
Co-authored-by: Cursor <cursoragent@cursor.com>
363 lines
14 KiB
JavaScript
363 lines
14 KiB
JavaScript
/**
|
||
* Складывает артефакты 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();
|