diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eef90d0 --- /dev/null +++ b/CHANGELOG.md @@ -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 проверяет diff --git a/scripts/release.sh b/scripts/release.sh index 91b467c..e7176b0 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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}") diff --git a/src/bridge/version.ts b/src/bridge/version.ts index 73f1efc..fb502b4 100644 --- a/src/bridge/version.ts +++ b/src/bridge/version.ts @@ -84,29 +84,48 @@ async function fetchLatestTag(): Promise { } } -/** 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 { +/** + * 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 { + const map: Record = {}; + 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> { 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, current: string, fallback: string[], @@ -174,12 +194,12 @@ export async function checkVersion(): Promise { 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 { 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) : [], }; } diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 0000000..7a611fc --- /dev/null +++ b/tests/version.test.ts @@ -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'); + }); +});