release.sh: переставлять split-ветки принудительно

git subtree split -b отказывается двигать существующую ветку, если новый
коммит ей не потомок — после reset в ядре ветка молча оставалась старой,
и продукт тянул устаревшую половину. Ветки производные, поэтому branch -f.

Поймано doctor'ом: сверка поддерева с ядром показала расхождение серверной
половины при совпадающей браузерной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
2026-08-26 20:45:08 +03:00
co-authored by Claude Opus 5
parent 0c7fee73a6
commit 7977ea6738
3 changed files with 76 additions and 21 deletions
+40 -8
View File
@@ -15,6 +15,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url)); const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -47,6 +48,24 @@ const SERVER_CORE = path.join(BACK, 'core');
// «Demo Panel» в нём норма, а не недоделка. Узнаётся по каталогу core/ рядом. // «Demo Panel» в нём норма, а не недоделка. Узнаётся по каталогу core/ рядом.
const IS_TEMPLATE = fs.existsSync(path.join(PANEL, 'core', 'browser')); const IS_TEMPLATE = fs.existsSync(path.join(PANEL, 'core', 'browser'));
// Ядро попадает в панель одним из двух способов, и проверки у них разные:
//
// subtree — панель лежит в git-репозитории продукта, ядро подтянуто
// `git subtree add` и потому отслеживается git. Обновляется
// `git subtree pull`; update.sh здесь не нужен и вреден — он
// сносит каталог целиком и затёр бы поддерево.
//
// copy — ядро раскладывает update.sh из соседнего ma7engine. Так устроен
// сам шаблон, и так живёт панель вне git.
function coreIsTracked(dir) {
try {
const out = execFileSync('git', ['ls-files', '--', dir],
{ cwd: PANEL, stdio: ['ignore', 'pipe', 'ignore'] });
return out.toString().trim() !== '';
} catch { return false; }
}
const MODE = !IS_TEMPLATE && coreIsTracked(path.join(FRONT, 'src/core')) ? 'subtree' : 'copy';
// ─── накопитель результатов ───────────────────────────────────────────────── // ─── накопитель результатов ─────────────────────────────────────────────────
const errors = [], warns = [], skips = [], oks = []; const errors = [], warns = [], skips = [], oks = [];
@@ -71,20 +90,33 @@ if (!fs.existsSync(FRONT)) {
} }
const updateSh = path.join(FRONT, 'scripts/update.sh'); const updateSh = path.join(FRONT, 'scripts/update.sh');
if (fs.existsSync(updateSh)) ok('update.sh на месте'); if (MODE === 'subtree') {
if (fs.existsSync(updateSh)) {
err('в subtree-панели остался frontend/scripts/update.sh',
'он сносит каталог ядра целиком (rm -rf) и затрёт подтянутое поддерево — ' +
'обновление здесь делается git subtree pull');
} else ok('ядро приходит через git subtree (update.sh не нужен)');
} else if (fs.existsSync(updateSh)) ok('update.sh на месте');
else err('нет frontend/scripts/update.sh', 'без него ядро в панели не обновляется'); else err('нет frontend/scripts/update.sh', 'без него ядро в панели не обновляется');
const frontPkgText = need(path.join(FRONT, 'package.json'), 'фронт'); const frontPkgText = need(path.join(FRONT, 'package.json'), 'фронт');
const frontPkg = frontPkgText ? JSON.parse(frontPkgText) : null; const frontPkg = frontPkgText ? JSON.parse(frontPkgText) : null;
if (frontPkg) { if (frontPkg) {
const s = frontPkg.scripts ?? {}; const s = frontPkg.scripts ?? {};
if (/update\.sh/.test(s.update ?? '')) ok('скрипт update ведёт на scripts/update.sh'); if (MODE === 'subtree') {
else err('frontend/package.json: скрипт "update" не зовёт scripts/update.sh'); const stale = ['update', 'predev', 'prebuild'].filter(h => /update/.test(s[h] ?? ''));
if (stale.length) err(`frontend/package.json: остались хуки копирования (${stale.join(', ')})`,
'ядро приходит поддеревом — эти скрипты его затрут');
else ok('в package.json нет хуков копирования ядра');
} else {
if (/update\.sh/.test(s.update ?? '')) ok('скрипт update ведёт на scripts/update.sh');
else err('frontend/package.json: скрипт "update" не зовёт scripts/update.sh');
for (const hook of ['predev', 'prebuild']) { for (const hook of ['predev', 'prebuild']) {
if (/update/.test(s[hook] ?? '')) ok(`${hook} обновляет ядро`); if (/update/.test(s[hook] ?? '')) ok(`${hook} обновляет ядро`);
else err(`frontend/package.json: нет "${hook}": "npm run update"`, else err(`frontend/package.json: нет "${hook}": "npm run update"`,
'без него dev и build идут на устаревшей копии ядра'); 'без него dev и build идут на устаревшей копии ядра');
}
} }
} }
@@ -330,7 +362,7 @@ if (!IS_TEMPLATE) {
// ─── вывод ────────────────────────────────────────────────────────────────── // ─── вывод ──────────────────────────────────────────────────────────────────
const rel = path.relative(process.cwd(), PANEL) || '.'; const rel = path.relative(process.cwd(), PANEL) || '.';
console.log(`doctor: ${rel}${IS_TEMPLATE ? ' (шаблон ma7engine)' : ''}\n`); console.log(`doctor: ${rel}${IS_TEMPLATE ? ' (шаблон ma7engine)' : ` (ядро: ${MODE})`}\n`);
for (const m of oks) console.log(` ok ${m}`); for (const m of oks) console.log(` ok ${m}`);
for (const m of skips) console.log(` — ${m}`); for (const m of skips) console.log(` — ${m}`);
+30 -12
View File
@@ -76,7 +76,15 @@ function edit(relPath, pairs) {
fs.writeFileSync(file, text); fs.writeFileSync(file, text);
} }
edit('frontend/package.json', [['"ma7engine-example-frontend"', `"${slug}-frontend"`]]); edit('frontend/package.json', [
['"ma7engine-example-frontend"', `"${slug}-frontend"`],
// В панели ядро приезжает через git subtree, а не копированием: хуки
// predev/prebuild и сам update.sh здесь не нужны и вредны — update.sh
// сносит каталог целиком (rm -rf) и затёр бы подтянутое поддерево.
[` "predev": "npm run update",\n`, ''],
[` "update": "sh scripts/update.sh",\n`, ''],
[` "prebuild": "npm run update",\n`, ''],
]);
// Заголовок вкладки браузера — не то же, что branding.name в App.tsx: его видно // Заголовок вкладки браузера — не то же, что branding.name в App.tsx: его видно
// до того, как React смонтируется, и в закладках. // до того, как React смонтируется, и в закладках.
edit('frontend/index.html', [['<title>Demo Panel</title>', `<title>${display}</title>`]]); edit('frontend/index.html', [['<title>Demo Panel</title>', `<title>${display}</title>`]]);
@@ -193,6 +201,13 @@ for (const f of ['Dockerfile', '.dockerignore']) {
fs.copyFileSync(path.join(ROOT, f), path.join(target, f)); fs.copyFileSync(path.join(ROOT, f), path.join(target, f));
} }
// Доктор берётся из scripts/, а не тот, что мог приехать внутри frontend/:
// в шаблоне frontend/scripts/doctor.mjs — вендоренная копия, и она бывает
// старше исходника. Панель должна получить актуальные проверки.
fs.mkdirSync(path.join(target, 'frontend/scripts'), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'scripts/doctor.mjs'),
path.join(target, 'frontend/scripts/doctor.mjs'));
fs.mkdirSync(path.join(target, 'scripts'), { recursive: true }); fs.mkdirSync(path.join(target, 'scripts'), { recursive: true });
const dev = fs.readFileSync(path.join(ROOT, 'scripts/demo.sh'), 'utf8') const dev = fs.readFileSync(path.join(ROOT, 'scripts/demo.sh'), 'utf8')
.replace('# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.', .replace('# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.',
@@ -208,19 +223,15 @@ const dev = fs.readFileSync(path.join(ROOT, 'scripts/demo.sh'), 'utf8')
fs.writeFileSync(path.join(target, 'scripts/dev.sh'), dev, { mode: 0o755 }); fs.writeFileSync(path.join(target, 'scripts/dev.sh'), dev, { mode: 0o755 });
// ─── ядро ─────────────────────────────────────────────────────────────────── // ─── ядро ───────────────────────────────────────────────────────────────────
// Копия ядра кладётся сразу: в панели она закоммиченный исходник, и панель // Ядро сюда не копируется: его приносит `git subtree add` — командами ниже.
// должна собираться с первого коммита, а не после того, как кто-то вспомнит // Копия и поддерево не уживаются, поэтому update.sh в панель не едет вовсе.
// про `npm run update`. Путь к ядру известен — это мы сами. fs.rmSync(path.join(target, 'frontend/scripts/update.sh'), { force: true });
try {
execFileSync('sh', [path.join(target, 'frontend/scripts/update.sh')],
{ env: { ...process.env, MA7ENGINE: ROOT }, stdio: 'pipe' });
console.log('new-panel: ядро скопировано в frontend/src/core и backend/core');
} catch (e) {
console.error('new-panel: не удалось разложить ядро — запусти `npm run update` во фронте\n' +
String(e.stderr ?? e.message));
}
// ─── что дальше ───────────────────────────────────────────────────────────── // ─── что дальше ─────────────────────────────────────────────────────────────
// Панель — надстройка: она обычно лежит подкаталогом продуктового репозитория,
// и prefix для subtree считается от его корня. Угадать корень отсюда нельзя,
// поэтому в подсказке путь даётся относительно каталога панели.
const prefix = `${path.basename(target)}/`;
console.log(` console.log(`
new-panel: панель ${display} создана в ${target} new-panel: панель ${display} создана в ${target}
@@ -232,5 +243,12 @@ new-panel: панель ${display} создана в ${target}
Пароль и секрет обязательны — без них ядро не даст процессу подняться: Пароль и секрет обязательны — без них ядро не даст процессу подняться:
ADMIN_PASS=... ADMIN_JWT_SECRET=... ADMIN_PASS=... ADMIN_JWT_SECRET=...
Ядро приносит git subtree — из каталога панели в её репозитории:
git subtree add --prefix=${prefix}frontend/src/core ${ROOT} dist/browser --squash
git subtree add --prefix=${prefix}backend/core ${ROOT} dist/server --squash
Дальше обновляется тем же git subtree pull. Копировать ядро руками не нужно —
и не стоит: поддерево умеет merge и покажет конфликт, а копия молча затрёт.
Проверить, что панель собрана по стандарту: Проверить, что панель собрана по стандарту:
cd ${target}/frontend && npm run doctor`); cd ${target}/frontend && npm run doctor`);
+6 -1
View File
@@ -31,9 +31,14 @@ if [ -n "$(git status --porcelain)" ]; then
exit 1 exit 1
fi fi
# `git subtree split -b <ветка>` умеет только создать ветку и отказывается
# двигать существующую, если новый коммит ей не потомок, — а после любого
# reset/rebase в ядре это ровно так. Поэтому split считает коммит, а ветка
# переставляется принудительно: ветки производные, своей истории у них нет.
split() { # split <каталог> <ветка> split() { # split <каталог> <ветка>
printf 'release: %s → %s\n' "$1" "$2" printf 'release: %s → %s\n' "$1" "$2"
git subtree split --prefix="$1" -b "$2" -q rev=$(git subtree split --prefix="$1" -q)
git branch -f "$2" "$rev"
printf ' %s\n' "$(git log -1 --format='%h %s' "$2")" printf ' %s\n' "$(git log -1 --format='%h %s' "$2")"
} }