3 Commits

Author SHA1 Message Date
Ivan Fontosh 8fa8467db7 fix(ci): stream update feed squash push
Release / release (push) Successful in 7m10s
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
Ivan Fontosh af4c2616f2 chore: release 1.0.11
Release / release (push) Failing after 11m51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 13:49:35 +08:00
Ivan Fontosh 3877a6f2a6 feat(ci): squash updates repo history each release (DND_UPDATES_SQUASH_HISTORY)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 13:42:53 +08:00
5 changed files with 167 additions and 10 deletions
+1
View File
@@ -135,6 +135,7 @@ jobs:
ARTIFACT_MAC: ${{ github.workspace }}/_mac
ARTIFACT_LINUX: ${{ github.workspace }}/release
DND_FEED_TMP_ROOT: ${{ github.workspace }}/.dnd-feed-tmp
DND_UPDATES_SQUASH_HISTORY: '1'
GIT_COMMIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
+13 -1
View File
@@ -378,9 +378,21 @@ git push origin v1.0.1
---
## Линейная история в `UPDATES_REPO` (например DndGamePlayerUpdates)
Переписывается **только** публичный репозиторий из секрета **`UPDATES_REPO`**, ветка **`updates`**. Репозиторий с исходниками игры (**DndGamePlayer**) и его теги **не меняются**.
В 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`** и установщиков — они не меняются по смыслу.
Не запускайте **два релиза**, которые одновременно пушат feed, — возможна гонка и отказ **`--force-with-lease`**.
---
## Если push отклонён: `(fetch first)` / `rejected`
Пока job собирает артефакты, в **`updates`** мог успеть попасть **другой** коммит (второй релиз, ручная выкладка). Скрипт `sync-update-feed.mjs` перед push делает **`git fetch` + `git merge origin/updates`** (и после клона — то же в начале), плюс shallow **глубина по умолчанию 40** (`DND_UPDATES_CLONE_DEPTH`). Не запускайте **два релиза одного и того же репо одновременно** по двум тегам — возможны конфликты 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.
---
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "DndGamePlayer",
"version": "1.0.10",
"version": "1.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DndGamePlayer",
"version": "1.0.10",
"version": "1.0.12",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DndGamePlayer",
"version": "1.0.10",
"version": "1.0.12",
"description": "DNDGamePlayer — редактор и проигрыватель игр",
"main": "dist/main/index.cjs",
"scripts": {
+150 -6
View File
@@ -18,6 +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)
* DND_FEED_LARGE_FILE_BYTES — порог "большого" файла для дробления push в squash-режиме (по умолчанию 64 MiB)
*/
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
@@ -175,6 +177,143 @@ function sleepSyncSeconds(seconds) {
}
}
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,
@@ -252,10 +391,10 @@ function main() {
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 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 {
@@ -290,8 +429,13 @@ function main() {
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);
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?)');
}