fix(releases): проверять манифест и достоверность сведений о сборке
This commit is contained in:
1 parent
d5edfe5ef6
commit
1d27127b52
10 files changed
+135
-64
No files matched your search
@@ -6,7 +6,6 @@ import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-v
|
|||||||
import {
|
import {
|
||||||
formatCommitSubject,
|
formatCommitSubject,
|
||||||
getReleaseCommitPreview,
|
getReleaseCommitPreview,
|
||||||
RELEASE_COMMIT_PREVIEW_LIMIT,
|
|
||||||
} from "@/lib/release-commits"
|
} from "@/lib/release-commits"
|
||||||
import { readReleaseManifest } from "@/lib/release-manifest"
|
import { readReleaseManifest } from "@/lib/release-manifest"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -16,23 +15,23 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
||||||
if (value) {
|
if (value) {
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(new Date(value))
|
}).format(new Date(value))
|
||||||
}
|
}
|
||||||
return isProdBuild ? "Прод-сборка" : "Локальная сборка"
|
return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReleasesPage() {
|
export default function ReleasesPage() {
|
||||||
const manifest = readReleaseManifest()
|
const manifest = readReleaseManifest()
|
||||||
const version = getAppVersion()
|
const version = getAppVersion()
|
||||||
const isProdBuild = !version.endsWith("-dev")
|
const isProdBuild = !version.endsWith("-dev")
|
||||||
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
|
||||||
const commits = getReleaseCommitPreview(manifest.commits)
|
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex min-h-0 flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
||||||
actions={
|
actions={
|
||||||
@@ -40,53 +39,55 @@ export default function ReleasesPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={releaseUrl}
|
href={releaseUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-4" />
|
<ExternalLinkIcon className="size-4" aria-hidden />
|
||||||
Открыть в Gitea
|
Открыть релиз
|
||||||
</Link>
|
</Link>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
<div className="max-w-[960px] mx-auto space-y-6">
|
<div className="max-w-[960px] mx-auto space-y-6">
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Текущая версия"
|
title="Текущая версия"
|
||||||
description="Сборка MikrotikManager, опубликованная через CI/CD."
|
description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
|
||||||
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
<span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
|
||||||
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
|
<span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||||
</p>
|
</p>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
|
{!manifest ? <p role="alert" className="text-sm text-destructive">Метаданные релиза недоступны или повреждены. Дата, ссылка и список коммитов не подтверждены.</p> : manifest.version !== version && isProdBuild ? <p role="status" className="text-sm text-muted-foreground">Манифест относится к {formatAppVersionLabel(manifest.version)}; версия запущенной сборки отличается.</p> : null}
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Сводка коммитов"
|
title="Сводка коммитов"
|
||||||
description={
|
description={
|
||||||
commits.length > 0
|
commits.length > 0
|
||||||
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
|
||||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."
|
: manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
|
||||||
}
|
}
|
||||||
contentClassName="px-5 pb-5"
|
contentClassName="px-5 pb-5"
|
||||||
>
|
>
|
||||||
{commits.length === 0 ? (
|
{commits.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
|
<p className="text-sm text-muted-foreground">{manifest ? "Коммитов для отображения пока нет." : "Нет подтверждённых метаданных."}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{commits.map((commit) => (
|
{commits.map((commit) => (
|
||||||
<li key={commit.sha} className="rounded-md border px-3 py-2">
|
<li key={commit.sha} className="rounded-md border px-3 py-2 break-words">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="font-mono uppercase">{commit.type}</span>
|
<span className="font-mono uppercase">{commit.type}</span>
|
||||||
<span className="font-mono">{commit.shortSha}</span>
|
<span className="font-mono">{commit.shortSha}</span>
|
||||||
<span>{commit.author}</span>
|
<span>{commit.author}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p>
|
<p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
|
|||||||
import {
|
import {
|
||||||
formatCommitSubject,
|
formatCommitSubject,
|
||||||
getReleaseCommitPreview,
|
getReleaseCommitPreview,
|
||||||
RELEASE_COMMIT_PREVIEW_LIMIT,
|
|
||||||
} from "@/lib/release-commits"
|
} from "@/lib/release-commits"
|
||||||
import type { ReleaseManifest } from "@/lib/release-manifest"
|
import {parseReleaseManifest,shouldShowReleaseNotes,type ReleaseManifest} from "@/lib/release-metadata"
|
||||||
import { hasSeenReleaseModal, markReleaseModalSeen } from "@/lib/release-modal-storage"
|
import { hasSeenReleaseModal, markReleaseModalSeen } from "@/lib/release-modal-storage"
|
||||||
|
|
||||||
export function ReleaseNotesModal() {
|
export function ReleaseNotesModal() {
|
||||||
@@ -27,26 +26,30 @@ export function ReleaseNotesModal() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
const controller=new AbortController()
|
||||||
|
const timeout=setTimeout(()=>controller.abort(),8000)
|
||||||
|
|
||||||
async function loadManifest() {
|
async function loadManifest() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/release-manifest.json", { cache: "no-store" })
|
const response = await fetch("/release-manifest.json", { cache: "no-store", signal:controller.signal })
|
||||||
if (!response.ok) return
|
if (!response.ok) return
|
||||||
|
|
||||||
const data = (await response.json()) as ReleaseManifest
|
const data = parseReleaseManifest(await response.json())
|
||||||
if (cancelled || !data.commits?.length || hasSeenReleaseModal(data.tag)) return
|
if (cancelled || controller.signal.aborted || !data || !shouldShowReleaseNotes(data,getAppVersion()) || hasSeenReleaseModal(data.tag)) return
|
||||||
|
|
||||||
setManifest(data)
|
setManifest(data)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
} catch {
|
} catch {
|
||||||
// Локальная сборка или недоступный manifest — модалку не показываем.
|
// Локальная сборка или недоступный manifest — модалку не показываем.
|
||||||
}
|
} finally {clearTimeout(timeout)}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadManifest()
|
void loadManifest()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
controller.abort()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -68,7 +71,7 @@ export function ReleaseNotesModal() {
|
|||||||
Что нового в {versionLabel}
|
Что нового в {versionLabel}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm leading-relaxed">
|
<DialogDescription className="text-sm leading-relaxed">
|
||||||
Последние {RELEASE_COMMIT_PREVIEW_LIMIT} коммитов текущей сборки MikrotikManager.
|
Коммиты текущей сборки MikrotikManager: {commits.length}.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ export function ReleaseNotesModal() {
|
|||||||
{commits.map((commit) => (
|
{commits.map((commit) => (
|
||||||
<li
|
<li
|
||||||
key={commit.sha}
|
key={commit.sha}
|
||||||
className="rounded-lg border bg-card/80 p-3"
|
className="rounded-lg border bg-card/80 p-3 break-words"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<Badge variant="secondary" className="font-mono uppercase">
|
<Badge variant="secondary" className="font-mono uppercase">
|
||||||
@@ -99,6 +102,7 @@ export function ReleaseNotesModal() {
|
|||||||
<DialogFooter className="mx-0 mb-0 shrink-0 gap-3 border-t bg-muted/30 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
<DialogFooter className="mx-0 mb-0 shrink-0 gap-3 border-t bg-muted/30 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
nativeButton={false}
|
||||||
render={<Link href="/releases" />}
|
render={<Link href="/releases" />}
|
||||||
onClick={() => handleOpenChange(false)}
|
onClick={() => handleOpenChange(false)}
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Актуальная точка продолжения — 2026-10-03
|
## Актуальная точка продолжения — 2026-10-03
|
||||||
|
|
||||||
Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.11; основной Alerts экран принят локально на ПК/320px. Следующий блок 11Б.5: проверить достоверность release manifest, безопасные ссылки и локальное/неизвестное состояние Releases, затем probe scheduling и приёмка 11Б/12. Processing recovery остаётся отдельной задачей с доказуемой остановкой всех отправителей; внешняя PG/Telegram приёмка обязательна перед выпуском.
|
Этап 11Б в работе; последний проверенный блок 11Б.5; Alerts и Releases приняты в границах локальной проверки. Следующий блок 11Б.6: достоверное чтение live speed-test в расписании диагностики, права uptime:read, повтор/последние данные и неизвестный RX без нуля; затем source/адаптивная приёмка и сводная приёмка 11Б/12. Processing recovery требует отдельного доказательства остановки всех отправителей.
|
||||||
|
|
||||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.11; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.5; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +36,14 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11Б.5 — достоверность метаданных релиза (2026-10-03)
|
||||||
|
|
||||||
|
- Runtime parser проверяет version/tag/date/commit fields, уникальные SHA и short SHA; malformed/missing JSON даёт unknown вместо фиктивного fallback v1.0.0. Подтверждённый пустой манифест остаётся пустым. Ссылки допускают только абсолютные HTTP(S) без credentials; rel=noopener noreferrer. Версии вручную не менялись.
|
||||||
|
- Страница разделяет текущую локальную/production версию и манифест, не утверждает публикацию по файлу CI. Дата обозначена датой манифеста и не приписывается несовпадающей текущей версии. Сводка показывает фактическое число preview записей; длинные строки переносятся.
|
||||||
|
- Общее окно Что нового использует тот же parser, появляется только для совпадающей production версии с коммитами. Fetch bounded/abort-on-unmount; ссылка-кнопка Base UI использует nativeButton=false. Installed primitives не изменены.
|
||||||
|
- 3 unit и 3 browser (320/1440, corrupted JSON и длинная запись/safe link) прошли, typecheck/lint и финальная production build frontend/contracts прошли; ReUI checklist применён, screenshot320 просмотрен. Browser fixture восстанавливает исходные bytes public/release-manifest.json в finally, diff отсутствует. Первый long-entry тест нашёл дубликат из лишней локальной модалки; причина исправлена.
|
||||||
|
- Реальная публикация в Gitea/CI не проверена. Следующий блок 11Б.6: достоверное чтение live speed-test в расписании диагностики, права uptime:read, повтор/последние данные и неизвестный RX без нуля; затем source/адаптивная приёмка и сводная приёмка 11Б/12. Processing recovery требует отдельного доказательства остановки всех отправителей.
|
||||||
|
|
||||||
## Блок 11Б.4.4.3.11 — приоритет правил и мобильная компоновка (2026-10-03)
|
## Блок 11Б.4.4.3.11 — приоритет правил и мобильная компоновка (2026-10-03)
|
||||||
|
|
||||||
- Правила и фильтры расположены перед каталогами, очередью, пресетами и подсказками. Независимые readers каталогов/очереди остаются смонтированы и доступны даже без подтверждённых основных данных. Формы, checkpoints, общий lock и API не изменены.
|
- Правила и фильтры расположены перед каталогами, очередью, пресетами и подсказками. Независимые readers каталогов/очереди остаются смонтированы и доступны даже без подтверждённых основных данных. Формы, checkpoints, общий lock и API не изменены.
|
||||||
|
|||||||
@@ -324,3 +324,7 @@ Live BGP-prefix честно помечен unsupported, legacy записи н
|
|||||||
### Продолжение 2026-10-03 — 11Б.4.4.3.11
|
### Продолжение 2026-10-03 — 11Б.4.4.3.11
|
||||||
|
|
||||||
Приоритет правил, доступные фильтры/поиск и мобильная строка приняты локально: browser35 + повтор12, typecheck/lint/ReUI audit, screenshot320. Readers/формы/guards сохранены. Следующий блок 11Б.5: проверить достоверность release manifest, безопасные ссылки и локальное/неизвестное состояние Releases, затем probe scheduling и приёмка 11Б/12. Processing recovery остаётся отдельной задачей с доказуемой остановкой всех отправителей; внешняя PG/Telegram приёмка обязательна перед выпуском.
|
Приоритет правил, доступные фильтры/поиск и мобильная строка приняты локально: browser35 + повтор12, typecheck/lint/ReUI audit, screenshot320. Readers/формы/guards сохранены. Следующий блок 11Б.5: проверить достоверность release manifest, безопасные ссылки и локальное/неизвестное состояние Releases, затем probe scheduling и приёмка 11Б/12. Processing recovery остаётся отдельной задачей с доказуемой остановкой всех отправителей; внешняя PG/Telegram приёмка обязательна перед выпуском.
|
||||||
|
|
||||||
|
### Продолжение 2026-10-03 — 11Б.5
|
||||||
|
|
||||||
|
Releases strict metadata/unknown/local/безопасные ссылки и current-production announcement приняты локально. Unit3/browser3/typecheck/lint/final production build прошли, screenshot320 просмотрен; публикация CI/Gitea не проверена. Следующий блок 11Б.6: достоверное чтение live speed-test в расписании диагностики, права uptime:read, повтор/последние данные и неизвестный RX без нуля; затем source/адаптивная приёмка и сводная приёмка 11Б/12. Processing recovery требует отдельного доказательства остановки всех отправителей.
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import {safeReleaseUrl} from "./release-metadata"
|
||||||
import packageJson from "@/package.json"
|
import packageJson from "@/package.json"
|
||||||
|
|
||||||
const packageVersion = packageJson.version
|
const packageVersion = packageJson.version
|
||||||
@@ -12,5 +13,5 @@ export function formatAppVersionLabel(version = getAppVersion()): string {
|
|||||||
|
|
||||||
export function getReleaseUrl(): string | null {
|
export function getReleaseUrl(): string | null {
|
||||||
const value = process.env.NEXT_PUBLIC_RELEASE_URL?.trim()
|
const value = process.env.NEXT_PUBLIC_RELEASE_URL?.trim()
|
||||||
return value || null
|
return safeReleaseUrl(value)
|
||||||
}
|
}
|
||||||
+6
-35
@@ -1,36 +1,7 @@
|
|||||||
import { readFileSync } from "node:fs"
|
import {readFileSync} from "node:fs"
|
||||||
import { join } from "node:path"
|
import {join} from "node:path"
|
||||||
|
import {parseReleaseManifest,type ReleaseManifest} from "./release-metadata"
|
||||||
export interface ReleaseCommit {
|
export type {ReleaseManifest,ReleaseCommit} from "./release-metadata"
|
||||||
sha: string
|
export function readReleaseManifest():ReleaseManifest|null {
|
||||||
shortSha: string
|
try{return parseReleaseManifest(JSON.parse(readFileSync(join(process.cwd(),"public","release-manifest.json"),"utf8")))}catch{return null}
|
||||||
subject: string
|
|
||||||
author: string
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReleaseManifest {
|
|
||||||
version: string
|
|
||||||
tag: string
|
|
||||||
publishedAt: string | null
|
|
||||||
releaseUrl: string
|
|
||||||
commits: ReleaseCommit[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackManifest: ReleaseManifest = {
|
|
||||||
version: "1.0.0",
|
|
||||||
tag: "v1.0.0",
|
|
||||||
publishedAt: null,
|
|
||||||
releaseUrl: "",
|
|
||||||
commits: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readReleaseManifest(): ReleaseManifest {
|
|
||||||
try {
|
|
||||||
const filePath = join(process.cwd(), "public", "release-manifest.json")
|
|
||||||
const raw = readFileSync(filePath, "utf8")
|
|
||||||
return JSON.parse(raw) as ReleaseManifest
|
|
||||||
} catch {
|
|
||||||
return fallbackManifest
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {test} from "node:test"
|
||||||
|
import {parseReleaseManifest,safeReleaseUrl,shouldShowReleaseNotes} from "./release-metadata"
|
||||||
|
const empty={version:"1.0.0",tag:"v1.0.0",publishedAt:null,releaseUrl:"",commits:[]}
|
||||||
|
const commit={sha:"a".repeat(40),shortSha:"a".repeat(7),subject:"fix(ui): исправить форму",author:"Fixture",type:"fix"}
|
||||||
|
test("release metadata distinguishes confirmed empty and rejects malformed/inconsistent manifests",()=>{
|
||||||
|
assert.deepEqual(parseReleaseManifest(empty),empty)
|
||||||
|
for(const row of [null,[],{}, {...empty,commits:null},{...empty,publishedAt:"invalid"},{...empty,publishedAt:"2026-01-01"},{...empty,tag:"v2.0.0"},{...empty,commits:[{...commit,subject:12}]},{...empty,commits:[commit,commit]},{...empty,commits:[{...commit,shortSha:"bbbbbbb"}]}])assert.equal(parseReleaseManifest(row),null)
|
||||||
|
assert.equal(parseReleaseManifest({...empty,publishedAt:"2026-10-03T00:00:00Z",commits:[commit]})?.commits.length,1)
|
||||||
|
})
|
||||||
|
test("release links allow only absolute HTTP(S) without credentials",()=>{
|
||||||
|
for(const link of ["javascript:alert(1)","data:text/html,fixture","//example.test/releases","/releases","https://user:secret@example.test/releases",null]){assert.equal(safeReleaseUrl(link),null);assert.equal(parseReleaseManifest({...empty,releaseUrl:link}),null)}
|
||||||
|
assert.equal(safeReleaseUrl(" https://example.test/releases "),"https://example.test/releases")
|
||||||
|
assert.equal(parseReleaseManifest({...empty,releaseUrl:"http://gitea.example.test/releases"})?.releaseUrl,"http://gitea.example.test/releases")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("release announcement belongs only to the current production build",()=>{
|
||||||
|
const manifest={...empty,commits:[commit]};assert.equal(shouldShowReleaseNotes(manifest,"1.0.0"),true);assert.equal(shouldShowReleaseNotes(manifest,"1.0.0-dev"),false);assert.equal(shouldShowReleaseNotes(manifest,"2.0.0"),false);assert.equal(shouldShowReleaseNotes(empty,"1.0.0"),false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export interface ReleaseCommit {
|
||||||
|
sha:string; shortSha:string; subject:string; author:string; type:string
|
||||||
|
}
|
||||||
|
export interface ReleaseManifest {
|
||||||
|
version:string; tag:string; publishedAt:string|null; releaseUrl:string; commits:ReleaseCommit[]
|
||||||
|
}
|
||||||
|
export function safeReleaseUrl(value:unknown):string|null {
|
||||||
|
if(typeof value!=="string"||!value.trim())return null
|
||||||
|
try{const url=new URL(value.trim());return ["https:","http:"].includes(url.protocol)&&!url.username&&!url.password?url.href:null}catch{return null}
|
||||||
|
}
|
||||||
|
const text=(value:unknown,max:number):value is string=>typeof value==="string"&&value.length>0&&value.length<=max
|
||||||
|
export function parseReleaseManifest(value:unknown):ReleaseManifest|null {
|
||||||
|
if(!value||typeof value!=="object"||Array.isArray(value))return null
|
||||||
|
const row=value as Record<string,unknown>
|
||||||
|
if(!text(row.version,100)||!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(row.version)||row.tag!=="v"+row.version)return null
|
||||||
|
if(row.publishedAt!==null&&(typeof row.publishedAt!=="string"||!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(row.publishedAt)||!Number.isFinite(Date.parse(row.publishedAt))))return null
|
||||||
|
if(typeof row.releaseUrl!=="string"||(row.releaseUrl!==""&&!safeReleaseUrl(row.releaseUrl)))return null
|
||||||
|
if(!Array.isArray(row.commits)||row.commits.length>1000)return null
|
||||||
|
const commits:ReleaseCommit[]=[];const ids=new Set<string>()
|
||||||
|
for(const item of row.commits){
|
||||||
|
if(!item||typeof item!=="object"||Array.isArray(item))return null
|
||||||
|
const c=item as Record<string,unknown>
|
||||||
|
if(!text(c.sha,64)||! /^[a-f0-9]{7,64}$/i.test(c.sha)||ids.has(c.sha)||!text(c.shortSha,12)||! /^[a-f0-9]{4,12}$/i.test(c.shortSha)||!c.sha.startsWith(c.shortSha)||!text(c.subject,4000)||!text(c.author,500)||!text(c.type,30))return null
|
||||||
|
ids.add(c.sha);commits.push({sha:c.sha,shortSha:c.shortSha,subject:c.subject,author:c.author,type:c.type})
|
||||||
|
}
|
||||||
|
return {version:row.version,tag:row.tag as string,publishedAt:row.publishedAt as string|null,releaseUrl:row.releaseUrl? safeReleaseUrl(row.releaseUrl)!:"",commits}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowReleaseNotes(manifest:ReleaseManifest,currentVersion:string):boolean {
|
||||||
|
return !currentVersion.endsWith("-dev")&&manifest.version===currentVersion&&manifest.commits.length>0
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {test,expect} from "@playwright/test"
|
||||||
|
for(const width of [320,1440])test("Releases local build is not presented as published and fits/"+width,async({page})=>{
|
||||||
|
await page.setViewportSize({width,height:900})
|
||||||
|
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}))
|
||||||
|
await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:[]}))
|
||||||
|
await page.goto("/releases")
|
||||||
|
await expect(page.getByText("Локальная разработка. Публикация релиза не подтверждена.",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByText("Локальная сборка",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByText("Сводка коммитов",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByRole("link",{name:"Открыть релиз",exact:true})).toHaveCount(0)
|
||||||
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
||||||
|
await page.screenshot({path:"test-results/releases-local-"+width+".png",fullPage:true})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server component reads a local manifest. Restore the original bytes even on assertion failure.
|
||||||
|
test("Releases malformed metadata is unknown and long confirmed entries remain readable on mobile",async({page})=>{
|
||||||
|
const {readFileSync,writeFileSync}=await import("node:fs");const file="public/release-manifest.json";const original=readFileSync(file)
|
||||||
|
try{
|
||||||
|
await page.setViewportSize({width:320,height:900})
|
||||||
|
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:[]}))
|
||||||
|
writeFileSync(file,JSON.stringify({version:"1.0.0",commits:null}));await page.goto("/releases")
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Метаданные релиза недоступны или повреждены"})).toBeVisible()
|
||||||
|
await expect(page.getByText("Нет подтверждённых метаданных.",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByRole("link",{name:"Открыть релиз",exact:true})).toHaveCount(0)
|
||||||
|
const sha="a".repeat(40);const subject="fix(ui): "+"ДлинноеОписание".repeat(20)
|
||||||
|
writeFileSync(file,JSON.stringify({version:"1.0.0",tag:"v1.0.0",publishedAt:"2026-10-03T00:00:00Z",releaseUrl:"https://example.test/releases/tag/v1.0.0",commits:[{sha,shortSha:sha.slice(0,7),subject,author:"Fixture",type:"fix"}]}));await page.reload()
|
||||||
|
await expect(page.getByText("Коммиты манифеста v1.0.0: 1.",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByText(subject.replace("fix(ui): ",""),{exact:true})).toBeVisible()
|
||||||
|
const link=page.getByRole("link",{name:"Открыть релиз",exact:true});await expect(link).toHaveAttribute("href","https://example.test/releases/tag/v1.0.0");await expect(link).toHaveAttribute("rel","noopener noreferrer")
|
||||||
|
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
||||||
|
}finally{writeFileSync(file,original)}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user