Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eadfdce30 | |||
| 7e7827224d | |||
| 8fa8467db7 | |||
| af4c2616f2 | |||
| 3877a6f2a6 |
@@ -135,6 +135,7 @@ jobs:
|
|||||||
ARTIFACT_MAC: ${{ github.workspace }}/_mac
|
ARTIFACT_MAC: ${{ github.workspace }}/_mac
|
||||||
ARTIFACT_LINUX: ${{ github.workspace }}/release
|
ARTIFACT_LINUX: ${{ github.workspace }}/release
|
||||||
DND_FEED_TMP_ROOT: ${{ github.workspace }}/.dnd-feed-tmp
|
DND_FEED_TMP_ROOT: ${{ github.workspace }}/.dnd-feed-tmp
|
||||||
|
DND_UPDATES_SQUASH_HISTORY: '1'
|
||||||
GIT_COMMIT_TAG: ${{ github.ref_name }}
|
GIT_COMMIT_TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
import { BrowserWindow, safeStorage } from 'electron';
|
import { BrowserWindow, safeStorage } from 'electron';
|
||||||
@@ -10,7 +11,7 @@ import { isDndProductKey } from '../../shared/license/productKey';
|
|||||||
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
|
||||||
|
|
||||||
import { getOrCreateDeviceId } from './deviceId';
|
import { getOrCreateDeviceId } from './deviceId';
|
||||||
import { licenseEncryptedPath, preferencesPath } from './paths';
|
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
|
||||||
import { verifyLicenseToken } from './verifyLicenseToken';
|
import { verifyLicenseToken } from './verifyLicenseToken';
|
||||||
|
|
||||||
type Preferences = {
|
type Preferences = {
|
||||||
@@ -19,6 +20,8 @@ type Preferences = {
|
|||||||
|
|
||||||
type LicenseChangeListener = () => void;
|
type LicenseChangeListener = () => void;
|
||||||
|
|
||||||
|
const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');
|
||||||
|
|
||||||
const licenseChangeListeners = new Set<LicenseChangeListener>();
|
const licenseChangeListeners = new Set<LicenseChangeListener>();
|
||||||
|
|
||||||
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
|
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
|
||||||
@@ -75,23 +78,93 @@ export class LicenseService {
|
|||||||
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
|
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
private readSealedToken(): string | null {
|
/** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
|
||||||
const p = licenseEncryptedPath(this.userData);
|
private isInsecureFileStorageAllowed(): boolean {
|
||||||
if (!fs.existsSync(p)) return null;
|
const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
|
||||||
if (!safeStorage.isEncryptionAvailable()) {
|
return v === '1' || v === 'true' || v === 'yes';
|
||||||
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
|
}
|
||||||
}
|
|
||||||
|
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);
|
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 {
|
private writeSealedToken(token: string): void {
|
||||||
if (!safeStorage.isEncryptionAvailable()) {
|
|
||||||
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
|
|
||||||
}
|
|
||||||
fs.mkdirSync(this.userData, { recursive: true });
|
fs.mkdirSync(this.userData, { recursive: true });
|
||||||
const enc = safeStorage.encryptString(token);
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
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 {
|
private clearSealedTokenFile(): void {
|
||||||
@@ -100,6 +173,11 @@ export class LicenseService {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ok */
|
/* ok */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export function licenseEncryptedPath(userData: string): string {
|
|||||||
return path.join(userData, 'license.sealed');
|
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 {
|
export function deviceIdPath(userData: string): string {
|
||||||
return path.join(userData, 'device.id');
|
return path.join(userData, 'device.id');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
## Если 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,18 @@
|
|||||||
|
|
||||||
Токен не хранится открытым текстом в JSON userData: используется **Electron `safeStorage`** (на macOS — связка с Keychain, на Windows — DPAPI). Идентификатор устройства — отдельный файл `device.id` (не секрет). Принятие EULA — `preferences.json` (версия текста).
|
Токен не хранится открытым текстом в 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)
|
## Юридическое (D9)
|
||||||
|
|
||||||
Текст EULA в приложении (`app/renderer/legal/eulaRu.ts`) и формулировки про активацию/отзыв/устройства. Перед первым вводом ключа пользователь принимает EULA (версия `EULA_CURRENT_VERSION` в `app/shared/license/eulaVersion.ts`).
|
Текст EULA в приложении (`app/renderer/legal/eulaRu.ts`) и формулировки про активацию/отзыв/устройства. Перед первым вводом ключа пользователь принимает EULA (версия `EULA_CURRENT_VERSION` в `app/shared/license/eulaVersion.ts`).
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "DndGamePlayer",
|
"name": "DndGamePlayer",
|
||||||
"version": "1.0.10",
|
"version": "1.0.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "DndGamePlayer",
|
"name": "DndGamePlayer",
|
||||||
"version": "1.0.10",
|
"version": "1.0.13",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DndGamePlayer",
|
"name": "DndGamePlayer",
|
||||||
"version": "1.0.10",
|
"version": "1.0.13",
|
||||||
"description": "DNDGamePlayer — редактор и проигрыватель игр",
|
"description": "DNDGamePlayer — редактор и проигрыватель игр",
|
||||||
"main": "dist/main/index.cjs",
|
"main": "dist/main/index.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
* DND_FEED_TMP_ROOT — каталог для временного клона feed (по умолчанию GITHUB_WORKSPACE / TMPDIR / os.tmpdir); не используйте узкий /tmp на раннере
|
* 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_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_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 { execFileSync, spawnSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
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) {
|
function mergeOriginUpdates(work) {
|
||||||
const fetchRetries = Math.max(
|
const fetchRetries = Math.max(
|
||||||
1,
|
1,
|
||||||
@@ -252,10 +391,10 @@ function main() {
|
|||||||
const tmp = fs.mkdtempSync(path.join(tmpRoot, 'dnd-feed-'));
|
const tmp = fs.mkdtempSync(path.join(tmpRoot, 'dnd-feed-'));
|
||||||
const work = path.join(tmp, 'repo');
|
const work = path.join(tmp, 'repo');
|
||||||
|
|
||||||
const cloneDepth = Math.max(
|
const squash = feedSquashSingleCommitEnabled();
|
||||||
2,
|
const cloneDepth = squash
|
||||||
Math.min(200, Number.parseInt(process.env.DND_UPDATES_CLONE_DEPTH || '40', 10) || 40),
|
? 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();
|
const gh = gitHttpInlineFlags();
|
||||||
try {
|
try {
|
||||||
@@ -290,8 +429,13 @@ function main() {
|
|||||||
runGit(['add', '-A'], work);
|
runGit(['add', '-A'], work);
|
||||||
const st = execFileSync('git', ['status', '--porcelain'], { cwd: work }).toString().trim();
|
const st = execFileSync('git', ['status', '--porcelain'], { cwd: work }).toString().trim();
|
||||||
if (st) {
|
if (st) {
|
||||||
runGit(['commit', '-m', `update feed ${tag}`], work);
|
const msg = `update feed ${tag}`;
|
||||||
pushUpdatesBranch(work);
|
if (squash) {
|
||||||
|
publishSquashedUpdatesBranchStreamed(work, msg, tag);
|
||||||
|
} else {
|
||||||
|
runGit(['commit', '-m', msg], work);
|
||||||
|
pushUpdatesBranch(work);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('[sync-update-feed] nothing to commit (identical artifacts?)');
|
console.warn('[sync-update-feed] nothing to commit (identical artifacts?)');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user