Ченджлог из CHANGELOG.md вместо заголовков коммитов: парсер в version.ts (GitHub-путь через raw-файл на теге вместо compare-API), release.sh требует заполненный раздел версии, тесты парсера

This commit is contained in:
Folist 2026-08-29 08:38:55 +03:00
parent 0b4fefdedd
commit 897e357bb3
4 changed files with 173 additions and 28 deletions

45
CHANGELOG.md Normal file
View file

@ -0,0 +1,45 @@
# Changelog
Пользовательские описания релизов. Именно ЭТОТ файл (не заголовки коммитов) показывается в уведомлении об обновлении: раздел на версию, пункты — строки, начинающиеся с «- ». Заполняется ПЕРЕД тегированием релиза — scripts/release.sh откажется публиковать версию без её раздела здесь. Коммиты остаются техническими и пользователям не показываются.
## 0.4.10 — 2026-08-29
- Пометка правок из MAX в Telegram: ✏️ с временем (МСК по умолчанию, TZ в .env) — Telegram не показывает «изменено» у бот-правок; заодно правка группового сообщения больше не теряет авторский префикс
- README: команда установки с резервного зеркала (когда GitHub недоступен)
## 0.4.9 — 2026-08-25
- Безопасность и надёжность (полный аудит): зеркало и телеметрия переведены на HTTPS, ключ подписи релизов закреплён в install.sh, контейнер работает без root, авто-реконнект при повреждённом кадре MAX, корректное завершение по SIGTERM, таймауты скачиваний и лимиты памяти
- Настройка после установки: повторный ./setup.sh открывает меню — прокси Telegram с живой проверкой до записи, смена токена/группы, режим стикеров, проверка связи; README без веб-панели
## 0.4.8 — 2026-08-24
- 0.4.8: «Открыть личку» из ростера чинит — callback отвечаем до запроса к MAX, имя из кэша; ошибки отправки видимы в теме
## 0.4.7 — 2026-08-23
- 0.4.7: живое появление группы при добавлении восстановлено (тема создаётся для служебного события нового чата, без пустого префикса)
## 0.4.6 — 2026-08-23
- Пустое «Имя:» от служебных событий убрано; /help почищен под headless; setup.sh: «напишите в группу»
## 0.4.5 — 2026-08-22
- Ростер группы: кнопка «Открыть личку» → список участников → тап открывает 1:1
## 0.4.4 — 2026-08-22
- Ростер участников группы в карточке + свои сообщения метятся «Вы» при синхронизации
## 0.4.3 — 2026-08-22
- Автор сообщений в групповых чатах: в теме видно, кто написал
## 0.4.2 — 2026-08-22
- 0.4.2: самопроверка enforcement подписи обновлений (тег намеренно без подписи)
## 0.4.1 — 2026-08-22
- Подпись обновлений включена: релизы подписываются SSH-ключом, update.sh проверяет

View file

@ -56,12 +56,26 @@ echo ">> [2/4] push to mirror (${MIRROR_GIT_URL})"
git push "${MIRROR_GIT_URL}" "+refs/heads/main:refs/heads/main" "+refs/tags/*:refs/tags/*"
echo ">> [3/4] build latest.json (cumulative per-version changelog)"
PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
RANGE="${TAG}"
[ -n "${PREV_TAG}" ] && RANGE="${PREV_TAG}..${TAG}"
# This version's notes: commit subjects since the previous tag, minus the release-bump commit itself.
NEW_NOTES_JSON=$(git log --format='%s' "${RANGE}" | grep -viE '^Релиз |^Release ' \
| node -e "const l=require('fs').readFileSync(0,'utf8').split('\n').filter(Boolean); process.stdout.write(JSON.stringify(l))")
# This version's notes come from CHANGELOG.md's `## ${VERSION}` section — NOT from commit
# subjects: commits are technical, users see only what's deliberately written in the file.
# No section (or an empty one) = refuse to release, so notes can't be forgotten.
NEW_NOTES_JSON=$(node -e "
const fs = require('fs');
const version = '${VERSION}';
const map = {};
let cur = null;
for (const line of fs.readFileSync('CHANGELOG.md', 'utf8').split('\n')) {
const h = /^##\s+v?(\d+\.\d+\.\d+)\b/.exec(line);
if (h) { cur = h[1]; map[cur] ??= []; continue; }
if (cur && line.startsWith('- ')) { const n = line.slice(2).trim(); if (n) map[cur].push(n); }
}
const notes = map[version];
if (!notes || notes.length === 0) process.exit(1);
process.stdout.write(JSON.stringify(notes));
") || {
echo "!! В CHANGELOG.md нет раздела '## ${VERSION}' с пунктами — заполните его и повторите релиз." >&2
exit 1
}
# Accumulate into the map already published on the mirror so multi-version jumps keep every release.
EXISTING_JSON=$(curl -fsS "${MIRROR_HTTP_URL}/latest.json" 2>/dev/null || echo '{}')
LATEST_JSON=$(node scripts/build-latest-json.mjs "${VERSION}" "${TAG}" "${NEW_NOTES_JSON}" "${EXISTING_JSON}")

View file

@ -84,29 +84,48 @@ async function fetchLatestTag(): Promise<LatestVersionInfo | null> {
}
}
/** First-line messages of the commits between two refs (newest-first) via the compare API — the
* "what's new". Best-effort: [] on any failure (e.g. the running version has no matching tag). */
async function fetchChangelog(base: string, head: string): Promise<string[]> {
/**
* Parses CHANGELOG.md (the file, not git history — commits are technical and never
* shown to users) into a version -> notes map. Sections start with `## X.Y.Z` (an
* optional `v` prefix and anything after the version — a dash, a date — is ignored);
* notes are the section's `- ` bullet lines. Exported for tests.
*/
export function parseChangelogMd(text: string): Record<string, string[]> {
const map: Record<string, string[]> = {};
let current: string | null = null;
for (const line of text.split('\n')) {
const header = /^##\s+v?(\d+\.\d+\.\d+)\b/.exec(line);
if (header) {
current = header[1] as string;
map[current] ??= [];
continue;
}
if (current && line.startsWith('- ')) {
const note = line.slice(2).trim();
if (note) map[current]!.push(note);
}
}
return map;
}
/** CHANGELOG.md as published at `tag` on GitHub — the user-facing "what's new" source
* (replaces the old compare-API commit-subject list, which leaked technical commits).
* Best-effort: {} on any failure (older tags predate the file). */
async function fetchChangelogFromGitHub(tag: string): Promise<Record<string, string[]>> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS);
const res = await fetch(`https://api.github.com/repos/${REPO}/compare/${base}...${head}`, {
headers: { Accept: 'application/vnd.github+json' },
const res = await fetch(`https://raw.githubusercontent.com/${REPO}/${tag}/CHANGELOG.md`, {
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
if (!res.ok) {
logger.error(`GitHub compare API returned ${res.status} ${res.statusText}`);
return [];
logger.error(`GitHub raw CHANGELOG.md returned ${res.status} ${res.statusText}`);
return {};
}
const payload = (await res.json()) as { commits?: Array<{ commit?: { message?: string } }> };
// compare returns oldest-first; reverse so the newest change reads first.
return (payload.commits ?? [])
.map((c) => (c.commit?.message ?? '').split('\n')[0] ?? '')
.filter(Boolean)
.reverse();
return parseChangelogMd(await res.text());
} catch (err) {
logger.error('Failed to fetch changelog from GitHub', err);
return [];
logger.error('Failed to fetch CHANGELOG.md from GitHub', err);
return {};
}
}
@ -148,10 +167,11 @@ async function fetchLatestFromMirror(): Promise<
}
}
/** Flattens the mirror's per-version changelog map into a single newest-first list of every release
/** Flattens a per-version changelog map into a single newest-first list of every release
* strictly newer than `current` — so a multi-version jump shows all skipped versions' notes, not
* just the latest. Falls back to `fallback` (the newest release's notes) when the map is empty. */
function assembleMirrorChangelog(
* just the latest. Shared by the GitHub (CHANGELOG.md) and mirror (latest.json) paths. Falls back
* to `fallback` (the newest release's notes) when the map has nothing newer. Exported for tests. */
export function assembleChangelog(
changelogs: Record<string, string[]>,
current: string,
fallback: string[],
@ -174,12 +194,12 @@ export async function checkVersion(): Promise<VersionStatus> {
const current = getAppVersion();
const currentSemver = parseSemver(current);
// Primary source: GitHub tags (+ compare API for the changelog).
// Primary source: GitHub tags (+ CHANGELOG.md at the latest tag for the notes).
const ghLatest = await fetchLatestTag();
if (ghLatest) {
const latestSemver = parseSemver(ghLatest.version);
const updateAvailable = latestSemver != null && currentSemver != null && cmpSemver(latestSemver, currentSemver) > 0;
const changelog = updateAvailable ? await fetchChangelog(`v${current}`, ghLatest.tag) : [];
const changelog = updateAvailable ? assembleChangelog(await fetchChangelogFromGitHub(ghLatest.tag), current, []) : [];
return { current, latest: ghLatest, updateAvailable, changelog };
}
@ -194,8 +214,8 @@ export async function checkVersion(): Promise<VersionStatus> {
latest: { tag: mirror.tag, version: mirror.version },
updateAvailable,
// Cumulative: every version between `current` and latest, so a multi-version jump isn't
// reduced to just the newest release's notes (as the GitHub compare API would show).
changelog: updateAvailable ? assembleMirrorChangelog(mirror.changelogs, current, mirror.changelog) : [],
// reduced to just the newest release's notes.
changelog: updateAvailable ? assembleChangelog(mirror.changelogs, current, mirror.changelog) : [],
};
}

66
tests/version.test.ts Normal file
View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { parseChangelogMd, assembleChangelog } from '../src/bridge/version.js';
const SAMPLE = `# Changelog
Пояснительный текст — не пункт и не раздел.
## 0.4.10 — 2026-08-29
- Пометка правок из MAX
- README: команда установки с зеркала
## v0.4.9 — 2026-08-25
- Безопасность и надёжность
Просто строка без дефиса — игнорируется.
## 0.4.8
- Фикс «Открыть личку»
-
`;
describe('parseChangelogMd', () => {
it('splits sections by ## version headers and collects bullet lines', () => {
const map = parseChangelogMd(SAMPLE);
expect(map['0.4.10']).toEqual(['Пометка правок из MAX', 'README: команда установки с зеркала']);
expect(map['0.4.8']).toEqual(['Фикс «Открыть личку»']);
});
it('accepts an optional v prefix and ignores everything after the version in the header', () => {
expect(parseChangelogMd(SAMPLE)['0.4.9']).toEqual(['Безопасность и надёжность']);
});
it('ignores prose lines, empty bullets, and text outside sections', () => {
const map = parseChangelogMd(SAMPLE);
// "0.4.1" must not be created by the "0.4.10" header (version match is exact, not prefix)
expect(map['0.4.1']).toBeUndefined();
expect(Object.keys(map).sort()).toEqual(['0.4.10', '0.4.8', '0.4.9']);
});
it('returns an empty map for a file with no version sections', () => {
expect(parseChangelogMd('# Changelog\n\nпусто')).toEqual({});
});
});
describe('assembleChangelog', () => {
const map = {
'0.4.10': ['новое в 10'],
'0.4.9': ['новое в 9'],
'0.4.8': ['новое в 8'],
};
it('keeps only versions strictly newer than current, newest first, with version headers', () => {
expect(assembleChangelog(map, '0.4.8', [])).toEqual(['v0.4.10', 'новое в 10', 'v0.4.9', 'новое в 9']);
});
it('returns the fallback when nothing is newer than current', () => {
expect(assembleChangelog(map, '0.4.10', ['fallback'])).toEqual(['fallback']);
});
it('treats an unparseable current version as "show everything"', () => {
const out = assembleChangelog(map, 'dev', []);
expect(out[0]).toBe('v0.4.10');
expect(out).toContain('новое в 8');
});
});