From 8fa8467db787b710d5d656013b948e659f65623d Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Tue, 12 May 2026 14:14:54 +0800 Subject: [PATCH] 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 --- docs/GITEA_AUTO_UPDATE.md | 4 +- package-lock.json | 4 +- package.json | 2 +- scripts/sync-update-feed.mjs | 131 +++++++++++++++++++++++++++++------ 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/docs/GITEA_AUTO_UPDATE.md b/docs/GITEA_AUTO_UPDATE.md index b9c673f..4b01bc1 100644 --- a/docs/GITEA_AUTO_UPDATE.md +++ b/docs/GITEA_AUTO_UPDATE.md @@ -382,7 +382,7 @@ git push origin v1.0.1 Переписывается **только** публичный репозиторий из секрета **`UPDATES_REPO`**, ветка **`updates`**. Репозиторий с исходниками игры (**DndGamePlayer**) и его теги **не меняются**. -В workflow задано **`DND_UPDATES_SQUASH_HISTORY=1`**: после merge и подкладки артефактов скрипт делает **`git checkout --orphan`** → один корневой коммит → push с **`--force-with-lease`**, если локально уже есть **`refs/remotes/origin/updates`** (ветка на сервере была); иначе — обычный первый **`git push -u origin updates`**. Так на каждом релизе в истории остаётся **один** коммит с актуальным набором файлов — меньше трафик и диск при `clone`/`fetch` на раннере. +В workflow задано **`DND_UPDATES_SQUASH_HISTORY=1`**: после merge и подкладки артефактов скрипт делает **`git checkout --orphan`** и собирает историю **только текущего релиза**. Чтобы не отправлять один огромный pack (~700+ MiB) и не ловить `curl 55 Broken pipe`, загрузка идёт через временную ветку **`updates-upload-*`**: сначала small files, затем каждый большой файл отдельным push (порог **`DND_FEED_LARGE_FILE_BYTES`**, по умолчанию 64 MiB). Ветка **`updates`** передвигается на готовый финальный коммит только в конце через **`--force-with-lease`** (если ветка уже была) или обычный первый push. Пользователи не видят «полурелиз», потому что `updates` меняется только после полной загрузки. На **сервере Gitea** старые объекты коммитов остаются «висячими», пока не отработает **сборка мусора** репозитория (настройки сервера / ручной `git gc` в bare-репо). Для пользователей приложения важны только URL **`latest*.yml`** и установщиков — они не меняются по смыслу. @@ -392,7 +392,7 @@ git push origin v1.0.1 ## Если push отклонён: `(fetch first)` / `rejected` -Пока job собирает артефакты, в **`updates`** мог успеть попасть **другой** коммит (второй релиз, ручная выкладка). Скрипт `sync-update-feed.mjs` перед push (в режиме **без** squash) делает **`git fetch` + `git merge origin/updates`** (и после клона — то же в начале), плюс shallow **глубина** (`DND_UPDATES_CLONE_DEPTH`: по умолчанию **40**, при **`DND_UPDATES_SQUASH_HISTORY=1`** в скрипте по умолчанию **8**). В режиме **squash** перед push merge **не** выполняется — уже смерженное дерево сжимается в один коммит и отправляется force-with-lease. Не запускайте **два релиза одного и того же репо одновременно** по двум тегам — возможны конфликты merge. +Пока job собирает артефакты, в **`updates`** мог успеть попасть **другой** коммит (второй релиз, ручная выкладка). Скрипт `sync-update-feed.mjs` перед push (в режиме **без** squash) делает **`git fetch` + `git merge origin/updates`** (и после клона — то же в начале), плюс shallow **глубина** (`DND_UPDATES_CLONE_DEPTH`: по умолчанию **40**, при **`DND_UPDATES_SQUASH_HISTORY=1`** в скрипте по умолчанию **8**). В режиме **squash** перед финальным push merge **не** выполняется — уже смерженное дерево отправляется через временную ветку и затем публикуется force-with-lease. Не запускайте **два релиза одного и того же репо одновременно** по двум тегам — возможны конфликты merge. --- diff --git a/package-lock.json b/package-lock.json index 80cbf86..f43e72f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DndGamePlayer", - "version": "1.0.11", + "version": "1.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DndGamePlayer", - "version": "1.0.11", + "version": "1.0.12", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 9c99588..16aa635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DndGamePlayer", - "version": "1.0.11", + "version": "1.0.12", "description": "DNDGamePlayer — редактор и проигрыватель игр", "main": "dist/main/index.cjs", "scripts": { diff --git a/scripts/sync-update-feed.mjs b/scripts/sync-update-feed.mjs index a4086ff..7ba754f 100644 --- a/scripts/sync-update-feed.mjs +++ b/scripts/sync-update-feed.mjs @@ -18,7 +18,8 @@ * 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) + * 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'; @@ -180,16 +181,6 @@ 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'], { @@ -202,21 +193,24 @@ function hadOriginUpdatesRemoteRef(work) { } } -/** После squash не делаем merge перед push — вернёт старую историю с remote. */ -function pushUpdatesBranchAfterSquash(work) { +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)); - 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})`); + 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] push failed (attempt ${attempt}/${retries})`); + console.warn(`[sync-update-feed] ${label} failed (attempt ${attempt}/${retries})`); if (attempt < retries) { sleepSyncSeconds(20); } @@ -225,6 +219,101 @@ function pushUpdatesBranchAfterSquash(work) { 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, @@ -342,11 +431,7 @@ function main() { 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); + publishSquashedUpdatesBranchStreamed(work, msg, tag); } else { runGit(['commit', '-m', msg], work); pushUpdatesBranch(work);