11 Commits

Author SHA1 Message Date
Ivan Fontosh 2c03921d23 docs: updates repo size, GC, and 1.5 GiB expectations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:18:30 +08:00
Ivan Fontosh 285a1a9667 fix(update): disable differential download for Gitea generic feed (Range 400)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:16:33 +08:00
Ivan Fontosh 0e14180044 chore: release 1.0.15
Release / release (push) Successful in 8m28s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:00:40 +08:00
Ivan Fontosh cd3ba5fe07 fix(ci): prune old DNDGamePlayer artifacts from updates feed before copy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:53:25 +08:00
Ivan Fontosh 26f8a81631 fix(ci): more git push retries for feed; document HTTP 500 and temp branch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:48:39 +08:00
Ivan Fontosh 2dc7015f53 chore: release 1.0.14
Release / release (push) Failing after 10m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:34:26 +08:00
Ivan Fontosh 0eadfdce30 chore: release 1.0.13
Release / release (push) Successful in 8m41s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:16:32 +08:00
Ivan Fontosh 7e7827224d fix(license): optional file fallback when safeStorage unavailable (WSL)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:14:08 +08:00
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
9 changed files with 424 additions and 29 deletions
+3
View File
@@ -135,6 +135,9 @@ 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'
DND_GIT_PUSH_RETRIES: '8'
DND_GIT_PUSH_RETRY_SLEEP_SEC: '35'
GIT_COMMIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
+91 -13
View File
@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { BrowserWindow, safeStorage } from 'electron';
@@ -10,7 +11,7 @@ import { isDndProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId';
import { licenseEncryptedPath, preferencesPath } from './paths';
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
import { verifyLicenseToken } from './verifyLicenseToken';
type Preferences = {
@@ -19,6 +20,8 @@ type Preferences = {
type LicenseChangeListener = () => void;
const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');
const licenseChangeListeners = new Set<LicenseChangeListener>();
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
@@ -75,23 +78,93 @@ export class LicenseService {
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
}
private readSealedToken(): string | null {
const p = licenseEncryptedPath(this.userData);
if (!fs.existsSync(p)) return null;
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
}
/** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
private isInsecureFileStorageAllowed(): boolean {
const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
return v === '1' || v === 'true' || v === 'yes';
}
private deriveFallbackKey(): Buffer {
return crypto
.createHash('sha256')
.update('DNDGamePlayer.license.fallback.v1\0', 'utf8')
.update(this.deviceId, 'utf8')
.digest();
}
private readFallbackSealedToken(): string {
const p = licenseFallbackSealedPath(this.userData);
const buf = fs.readFileSync(p);
return safeStorage.decryptString(buf);
if (buf.length < FALLBACK_MAGIC.length + 12 + 16 + 1) {
throw new Error('license.fallback: файл повреждён или слишком короткий');
}
if (!buf.subarray(0, FALLBACK_MAGIC.length).equals(FALLBACK_MAGIC)) {
throw new Error('license.fallback: неверный формат');
}
const iv = buf.subarray(FALLBACK_MAGIC.length, FALLBACK_MAGIC.length + 12);
const tag = buf.subarray(FALLBACK_MAGIC.length + 12, FALLBACK_MAGIC.length + 12 + 16);
const data = buf.subarray(FALLBACK_MAGIC.length + 12 + 16);
const key = this.deriveFallbackKey();
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
}
private writeFallbackSealedToken(token: string): void {
const key = this.deriveFallbackKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = Buffer.concat([FALLBACK_MAGIC, iv, tag, enc]);
fs.writeFileSync(licenseFallbackSealedPath(this.userData), payload, { mode: 0o600 });
}
private readSealedToken(): string | null {
const sealedPath = licenseEncryptedPath(this.userData);
const fallbackPath = licenseFallbackSealedPath(this.userData);
if (fs.existsSync(sealedPath)) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error(
'safeStorage недоступен: есть license.sealed, но расшифровать нельзя (часто перенос профиля или WSL без keyring). Удалите файл лицензии в настройках приложения или используйте DND_LICENSE_INSECURE_FILE_STORAGE=1 и активируйте заново.',
);
}
const buf = fs.readFileSync(sealedPath);
return safeStorage.decryptString(buf);
}
if (fs.existsSync(fallbackPath)) {
return this.readFallbackSealedToken();
}
return null;
}
private writeSealedToken(token: string): void {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
}
fs.mkdirSync(this.userData, { recursive: true });
const enc = safeStorage.encryptString(token);
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
if (safeStorage.isEncryptionAvailable()) {
const enc = safeStorage.encryptString(token);
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
try {
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
} catch {
/* ok */
}
return;
}
if (this.isInsecureFileStorageAllowed()) {
this.writeFallbackSealedToken(token);
try {
fs.unlinkSync(licenseEncryptedPath(this.userData));
} catch {
/* ok */
}
return;
}
throw new Error(
'safeStorage недоступен: нельзя сохранить лицензию на этой системе (типично WSL без gnome-keyring). Запустите с переменной DND_LICENSE_INSECURE_FILE_STORAGE=1 — токен будет сохранён в зашифрованном файле (слабее OS-хранилища); либо настройте Secret Service / gnome-keyring.',
);
}
private clearSealedTokenFile(): void {
@@ -100,6 +173,11 @@ export class LicenseService {
} catch {
/* ok */
}
try {
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
} catch {
/* ok */
}
}
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
+5
View File
@@ -4,6 +4,11 @@ export function licenseEncryptedPath(userData: string): string {
return path.join(userData, 'license.sealed');
}
/** Fallback, если нет OS keychain (WSL без gnome-keyring и т.п.); только при DND_LICENSE_INSECURE_FILE_STORAGE=1. */
export function licenseFallbackSealedPath(userData: string): string {
return path.join(userData, 'license.sealed.fallback');
}
export function deviceIdPath(userData: string): string {
return path.join(userData, 'device.id');
}
+10
View File
@@ -77,6 +77,7 @@ function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseSe
/**
* Проверка обновлений: только упакованное приложение, только при активной лицензии.
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
* Дифференциальное скачивание (HTTP Range / blockmap) по умолчанию **выключено**: за nginx у Gitea raw часто **400** на multi-Range, updater всё равно уходит в полный файл. Включить снова: **`DND_UPDATE_ENABLE_DIFFERENTIAL=1`** (имеет смысл только если на сервере починили Range).
*/
export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void {
registerUpdaterHandlers(register, licenseService);
@@ -89,6 +90,15 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
autoUpdater.setFeedURL({ provider: 'generic', url });
}
// Дифференциальное обновление (multi-Range по blockmap) часто ломается на Gitea raw за nginx (HTTP 400);
// electron-updater тогда и так падает на полный файл — отключаем лишний шум и лишний round-trip.
const enableDiff = process.env.DND_UPDATE_ENABLE_DIFFERENTIAL?.trim().toLowerCase();
autoUpdater.disableDifferentialDownload = !(
enableDiff === '1' ||
enableDiff === 'true' ||
enableDiff === 'yes'
);
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
+52 -3
View File
@@ -325,6 +325,8 @@ sudo journalctl -u gitea-act-runner -f
- **Базовый дистрибутив для бинарников:** сборка на **Ubuntu 22.04 (glibc 2.35)** — совместимость с «максимумом» настольных дистрибутивов с glibc не старее целевого; **Alpine/musl** без отдельной сборки не гарантируется.
- **Запуск AppImage:** на части систем нужен **FUSE** (например `libfuse2` для старых форматов / документация дистрибутива). Подпись пакетов в первом варианте **не** используется (как договорённость по проекту).
- Правила **electron-updater** в приложении те же: упакованная сборка и **активная лицензия** (`installAutoUpdater.ts`).
- **Дифференциальное обновление** (запросы с несколькими HTTP `Range` по blockmap) на **Gitea raw** за nginx часто даёт **`400 Bad Request`** (`content-length: 18` — короткий текст ошибки); updater переходит на **полную** загрузку — обновление всё равно доходит. В коде по умолчанию **`disableDifferentialDownload: true`**, чтобы не делать лишний шаг (меньше шума в логе). Включить дифф снова: переменная **`DND_UPDATE_ENABLE_DIFFERENTIAL=1`** при запуске приложения — имеет смысл только если на сервере разрешили корректные Range / multi-range для raw.
- Сообщения вида **`duplicated in blockmap`** в консоли — предупреждение electron-updater, на работоспособность обычно не влияют.
---
@@ -362,13 +364,13 @@ git push origin v1.0.1
## Если `git push` в `updates` падает: `remote end hung up` / таймаут
Один коммит с **Windows + два AppImage** может быть **сотни МБ** — HTTPS-push иногда рвётся из‑за лимита буфера Git или таймаута **nginx / reverse proxy** перед Gitea.
Один коммит с **Windows + два AppImage** может быть **сотни МБ** — HTTPS-push иногда рвётся из‑за лимита буфера Git или таймаута **nginx / reverse proxy** перед Gitea. Отдельно встречается **`HTTP 500`** от Gitea/прокси на большом pack (например один `*-win.zip` ~200 MiB) — это уже **ошибка сервера** при приёме тела запроса; повторы push иногда помогают, но при стабильном 500 нужно править лимиты/таймауты на стороне Gitea и reverse proxy.
**В репозитории с кодом** скрипт `scripts/sync-update-feed.mjs` уже выставляет в клоне feed-репо:
- `http.postBuffer` **2 GiB**;
- отключение «медленной передачи» (`http.lowSpeedLimit` / `http.lowSpeedTime`);
- до **3** повторов `git push` с паузой 20 с (переменная **`DND_GIT_PUSH_RETRIES`**, максимум 5).
- повторы **`git push`**: переменная **`DND_GIT_PUSH_RETRIES`** (по умолчанию **5**, максимум **10**), пауза **`DND_GIT_PUSH_RETRY_SLEEP_SEC`** (по умолчанию **30** с, максимум **120**). В workflow для шага sync задано **`DND_GIT_PUSH_RETRIES=8`** и **`DND_GIT_PUSH_RETRY_SLEEP_SEC=35`**.
Если ошибка сохраняется — на **сервере** (nginx и т.п.) проверьте, например:
@@ -378,9 +380,56 @@ 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). Эта ветка **не заменяет** публичный feed: URL для клиентов по-прежнему **`…/raw/branch/updates/`**. `updates-upload-*` — только черновик на время job; в конце ветка **`updates`** передвигается на готовый коммит (`--force-with-lease` или первый push), временная ветка **удаляется** с remote.
На **сервере Gitea** старые объекты коммитов остаются «висячими», пока не отработает **сборка мусора** репозитория (настройки сервера / ручной `git gc` в bare-репо). Для пользователей приложения важны только URL **`latest*.yml`** и установщиков — они не меняются по смыслу.
Не запускайте **два релиза**, которые одновременно пушат feed, — возможна гонка и отказ **`--force-with-lease`**.
Перед подкладкой артефактов скрипт **удаляет** из корня ветки файлы вида **`DNDGamePlayer-<чужая версия>-*`** / **`DNDGamePlayer-Setup-<чужая версия>.exe`** (и связанные `.blockmap`), чтобы после `merge` не копились старые установщики (1.0.6 рядом с 1.0.14). Версия текущего релиза берётся из **`GIT_COMMIT_TAG`** (`v1.0.14``1.0.14`). Файлы **`latest*.yml`** перезаписываются копированием. Если в **`ARTIFACT_MAC`** нет ни одного файла сборки, **`.dmg` / `.pkg`** не удаляются — сохраняется вручную залитый mac в feed. Отключить очистку: **`DND_FEED_PRUNE_OLD_VERSIONS=0`**.
---
## Размер репозитория `DndGamePlayerUpdates` (почему ~4 GiB и как держать меньше)
### Почему место не возвращается само
При **`DND_UPDATES_SQUASH_HISTORY=1`** и **`git push --force-with-lease`** на ветке **`updates`** в истории остаётся по сути **один актуальный коммит**, но на **диске сервера** Git до **сборки мусора** продолжает хранить **недостижимые** объекты: старые коммиты, прерванные push, временные ветки **`updates-upload-*`**, старые pack-файлы. Пока не выполнен **`git gc --prune`**, размер каталога **`.git`** на Gitea может быть **сильно больше**, чем «один релиз файлов» в веб-интерфейсе.
### Сразу освободить место (один раз на сервере)
На машине Gitea найдите **bare**-репозиторий (типичный путь: `…/gitea-repositories/<владелец>/dndgameplayerupdates.git`), от имени пользователя с правами на каталог:
```bash
cd /path/to/DndGamePlayerUpdates.git
git gc --prune=now
```
При очень большом накоплении мусора иногда используют **`--aggressive`** (дольше и тяжелее для CPU); чаще достаточно команды **без** него. После GC размер на диске должен приблизиться к сумме **текущих** файлов в последнем коммите.
В веб-интерфейсе Gitea смотрите раздел **администрирования** / **обслуживание** / **garbage collection** (названия зависят от версии): там может быть глобальный GC по всем репозиториям.
### Чтобы после каждого релиза снова не раздувалось
В **`app.ini`** Gitea включите **периодический GC репозиториев** (секция и ключи зависят от версии; ориентир — задача **`git_gc_repos`** в документации [config cheat sheet](https://docs.gitea.com/administration/config-cheat-sheet)), например раз в **24 ч** или чаще, если релизы частые.
Уже включено в вашем пайплайне по смыслу: **squash** + **prune** старых имён установщиков — это уменьшает **набор файлов** в новом коммите; **физическое** освобождение на диске — задача **GC на сервере**.
### Можно ли «всегда не больше 1,5 GiB»
**Жёстко задать потолок Git не умеет.** Реальный минимум на диске ≈ размер **текущих** артефактов в ветке (два AppImage + NSIS + zip + blockmap + mac и т.д.) плюс служебные pack’и. Если один релиз по сумме бинарников уже **>1,5GiB**, репозиторий физически не уместится в лимит без **урезания артефактов** (например не выкладывать **`win.zip`**, если он не нужен updater’у) или без **выноса больших файлов из Git**.
Надёжный способ держать репо **маленьким постоянно**: в **`DndGamePlayerUpdates`** хранить только **`latest*.yml`** (и при необходимости подписи), а **`.exe` / `.AppImage` / `.zip`** выкладывать в **Gitea Releases**, **объектное хранилище** или **статический хост**, а в yml подставлять **прямые URL** туда. Тогда потребуется доработка **CI** и, при смене хоста, **`build.publish.url`** / секреты — это отдельная схема доставки, не только `git gc`.
---
## Если 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.
---
+12
View File
@@ -20,6 +20,18 @@
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста).
### Linux / WSL без keyring
На Linux `safeStorage` обычно требует **Secret Service** (например `gnome-keyring` + D-Bus). В **WSL** без keyring `safeStorage.isEncryptionAvailable()` часто **false**, и сохранить токен нельзя.
Явный обход (только если осознанно нужен запуск без OS-хранилища): переменная окружения **`DND_LICENSE_INSECURE_FILE_STORAGE=1`**. Тогда токен пишется в файл **`license.sealed.fallback`** (AES-256-GCM, ключ от `deviceId` + константа приложения). Это **слабее**, чем связка с ОС: при копировании `userData` + знании формата теоретически проще атаковать офлайн. Для обычного десктопа Linux с рабочим сеансом переменную не задавайте.
Пример запуска AppImage:
```bash
DND_LICENSE_INSECURE_FILE_STORAGE=1 ./DNDGamePlayer-1.0.12-x64.AppImage --no-sandbox --appimage-extract-and-run
```
## Юридическое (D9)
Текст EULA в приложении (`app/renderer/legal/eulaRu.ts`) и формулировки про активацию/отзыв/устройства. Перед первым вводом ключа пользователь принимает EULA (версия `EULA_CURRENT_VERSION` в `app/shared/license/eulaVersion.ts`).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "DndGamePlayer",
"version": "1.0.10",
"version": "1.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "DndGamePlayer",
"version": "1.0.10",
"version": "1.0.15",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DndGamePlayer",
"version": "1.0.10",
"version": "1.0.15",
"description": "DNDGamePlayer — редактор и проигрыватель игр",
"main": "dist/main/index.cjs",
"scripts": {
+248 -10
View File
@@ -3,7 +3,8 @@
* ветка `updates`, чтобы generic URL …/raw/branch/updates/ указывал на актуальные latest*.yml и установщики.
*
* Копирование **merge**: существующие файлы в ветке (другие ОС) не удаляются — обновляются только
* те имена, которые пришли из переданных каталогов артефактов.
* те имена, которые пришли из переданных каталогов артефактов. Перед копированием удаляются **устаревшие**
* установщики другой semver в имени (`DNDGamePlayer-1.0.6-…` при релизе `v1.0.14`), чтобы в feed не копились старые версии.
*
* Переменные окружения:
* DND_UPDATES_SERVER — https://git.example.com (без слэша в конце)
@@ -13,11 +14,15 @@
* ARTIFACT_MAC — каталог с файлами macOS
* ARTIFACT_LINUX — каталог с файлами Linux (AppImage и т.д.)
* GIT_COMMIT_TAG — опционально, для сообщения коммита
* DND_GIT_PUSH_RETRIES — опционально, число попыток git push (15, по умолчанию 3)
* DND_GIT_PUSH_RETRIES — число попыток git push (110, по умолчанию 5); при squash — для каждого push во временную ветку и финального push в updates
* DND_GIT_PUSH_RETRY_SLEEP_SEC — пауза между попытками push (5–120 с, по умолчанию 30)
* 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 переписывается на историю только текущего релиза. Загрузка идёт во временную ветку updates-upload-* (маленькие pack'и), затем публикуется updates; временная ветка удаляется — пользователям feed по-прежнему нужна только ветка updates.
* DND_FEED_LARGE_FILE_BYTES — порог "большого" файла для дробления push в squash-режиме (по умолчанию 64 MiB)
* DND_FEED_PRUNE_OLD_VERSIONS — если "0"/"false"/"no", не удалять старые DNDGamePlayer-* другой версии перед копированием
*/
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
@@ -96,6 +101,80 @@ function tryGitGcPrune(work) {
}
}
/** Тег релиза `v1.0.14` → `1.0.14` (с опциональным prerelease). */
function parseReleaseSemverFromGitTag() {
const t = (process.env.GIT_COMMIT_TAG || '').trim();
const m = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.+]+)?)$/.exec(t);
return m ? m[1] : null;
}
function macArtifactLooksLike(fileName) {
const lo = fileName.toLowerCase();
if (lo.endsWith('.dmg') || lo.endsWith('.pkg')) return true;
if (lo.endsWith('.zip') && /(?:^|-)mac(?:-|$)|darwin/i.test(fileName)) return true;
return false;
}
/**
* Версия из имени артефакта electron-builder (DNDGamePlayer + semver в имени).
* `latest*.yml` и прочее без semver — null.
*/
function extractVersionedDndArtifactSemver(fileName) {
const n = fileName;
let m = /^DNDGamePlayer-Setup-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.+]+)?)\.exe(?:\.blockmap)?$/i.exec(n);
if (m) return m[1];
m = /^DNDGamePlayer-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.+]+)?)-/i.exec(n);
if (m) return m[1];
m = /^DNDGamePlayer-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.+]+)?)\.(dmg|pkg)$/i.exec(n);
if (m) return m[1];
return null;
}
function artifactDirHasIncomingFiles(dir) {
if (!dir || !fs.existsSync(dir)) return false;
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
if (!fs.statSync(p).isFile()) continue;
const ext = path.extname(name).toLowerCase();
if (ALLOWED_EXT.has(ext)) return true;
}
return false;
}
/**
* Удаляет из корня клона старые установщики с другим semver (после merge они иначе остаются навсегда).
* Если в ARTIFACT_MAC нет файлов — .dmg/.pkg не трогаем (ручная заливка mac в feed).
*/
function pruneObsoleteDndReleaseArtifacts(work, currentSemver, macDir) {
const off = process.env.DND_FEED_PRUNE_OLD_VERSIONS?.trim().toLowerCase();
if (off === '0' || off === 'false' || off === 'no') return 0;
if (!currentSemver) {
console.warn('[sync-update-feed] prune: пропуск — GIT_COMMIT_TAG не похож на v1.2.3');
return 0;
}
const pruneMac = artifactDirHasIncomingFiles(macDir);
let removed = 0;
for (const name of fs.readdirSync(work)) {
if (name === '.git') continue;
const p = path.join(work, name);
if (!fs.statSync(p).isFile()) continue;
const ext = path.extname(name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) continue;
const fileVer = extractVersionedDndArtifactSemver(name);
if (!fileVer) continue;
if (fileVer === currentSemver) continue;
if (!pruneMac && macArtifactLooksLike(name)) continue;
fs.unlinkSync(p);
removed += 1;
}
if (removed > 0) {
console.warn(
`[sync-update-feed] prune: удалено устаревших артефактов (другая версия в имени): ${String(removed)}`,
);
}
return removed;
}
function mustEnv(name) {
const v = process.env[name]?.trim();
if (!v) throw new Error(`Missing env ${name}`);
@@ -175,6 +254,156 @@ 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 gitPushRetryCount() {
return Math.max(1, Math.min(10, Number.parseInt(process.env.DND_GIT_PUSH_RETRIES || '5', 10) || 5));
}
function gitPushRetrySleepSec() {
return Math.max(
5,
Math.min(120, Number.parseInt(process.env.DND_GIT_PUSH_RETRY_SLEEP_SEC || '30', 10) || 30),
);
}
function pushWithRetries(work, args, label) {
const retries = gitPushRetryCount();
const sleepSec = gitPushRetrySleepSec();
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(sleepSec);
}
}
}
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). ` +
`Ветка updates-upload-* служебная: после успеха на remote остаётся только updates; клиенты по-прежнему читают raw/.../updates/.`,
);
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,
@@ -215,7 +444,8 @@ function mergeOriginUpdates(work) {
}
function pushUpdatesBranch(work) {
const retries = Math.max(1, Math.min(5, Number.parseInt(process.env.DND_GIT_PUSH_RETRIES || '3', 10) || 3));
const retries = gitPushRetryCount();
const sleepSec = gitPushRetrySleepSec();
let lastError;
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
@@ -227,7 +457,7 @@ function pushUpdatesBranch(work) {
lastError = err;
console.warn(`[sync-update-feed] merge/push failed (attempt ${attempt}/${retries})`);
if (attempt < retries) {
sleepSyncSeconds(20);
sleepSyncSeconds(sleepSec);
}
}
}
@@ -252,10 +482,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 {
@@ -275,6 +505,9 @@ function main() {
mergeOriginUpdates(work);
const currentSemver = parseReleaseSemverFromGitTag();
pruneObsoleteDndReleaseArtifacts(work, currentSemver, macDir);
const artifactDirs = [...new Set([winDir, macDir, linuxDir].filter(Boolean))];
let copied = 0;
for (const d of artifactDirs) {
@@ -290,8 +523,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?)');
}