From 9ccc8459d77032dae8e7f67ac0df0287d7553341 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 12 May 2026 17:19:20 +0700 Subject: [PATCH] feat: update application versioning and enhance release management - Bumped application version to 1.0.0 across all relevant package files, ensuring consistency in versioning. - Introduced new environment variables for application version and release URL in Dockerfiles, improving deployment transparency. - Enhanced the health check endpoint to return the current application version, providing better visibility for monitoring. - Updated CI/CD workflows to include steps for preparing and publishing releases, streamlining the release process. - Added a new "Releases" section in the application sidebar for easier access to version information. --- .ci/release/release-manifest.json | 7 + .ci/release/release_notes.md | 0 .ci/scripts/compute-release.mjs | 238 +++++++++++++++++++++++++++ .cursor/rules/ma-global.mdc | 1 + .cursor/rules/ma-implementer.mdc | 1 + .cursor/rules/release-versioning.mdc | 32 ++++ .gitea/workflows/docker.yml | 151 ++++++++++++++++- Dockerfile.frontend | 4 + README.md | 13 +- app/(main)/releases/page.tsx | 93 +++++++++++ backend/Dockerfile | 4 + backend/package.json | 4 +- backend/src/index.ts | 6 +- components/app-sidebar.tsx | 5 +- components/command-palette.tsx | 1 + lib/app-version.ts | 16 ++ lib/release-manifest.ts | 36 ++++ package-lock.json | 27 ++- package.json | 4 +- packages/contracts/package.json | 2 +- public/release-manifest.json | 7 + 21 files changed, 630 insertions(+), 22 deletions(-) create mode 100644 .ci/release/release-manifest.json create mode 100644 .ci/release/release_notes.md create mode 100644 .ci/scripts/compute-release.mjs create mode 100644 .cursor/rules/release-versioning.mdc create mode 100644 app/(main)/releases/page.tsx create mode 100644 lib/app-version.ts create mode 100644 lib/release-manifest.ts create mode 100644 public/release-manifest.json diff --git a/.ci/release/release-manifest.json b/.ci/release/release-manifest.json new file mode 100644 index 0000000..3e9d2ee --- /dev/null +++ b/.ci/release/release-manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "tag": "v1.0.0", + "publishedAt": null, + "releaseUrl": "", + "commits": [] +} diff --git a/.ci/release/release_notes.md b/.ci/release/release_notes.md new file mode 100644 index 0000000..e69de29 diff --git a/.ci/scripts/compute-release.mjs b/.ci/scripts/compute-release.mjs new file mode 100644 index 0000000..18373dc --- /dev/null +++ b/.ci/scripts/compute-release.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process" +import { mkdirSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, "..", "..") +const baseVersion = process.env.BASE_VERSION ?? "1.0.0" +const outputDir = process.env.RELEASE_OUTPUT_DIR ?? join(repoRoot, ".ci", "release") +const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? "" +const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "") + +const CONVENTIONAL_RE = + /^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i +const MINOR_TYPES = new Set(["feat"]) +const PATCH_TYPES = new Set([ + "fix", + "chore", + "docs", + "refactor", + "style", + "test", + "build", + "ci", +]) + +function git(...args) { + return execFileSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim() +} + +function parseSemver(version) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version) + if (!match) { + throw new Error(`Invalid semver: ${version}`) + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + } +} + +function formatSemver(parts) { + return `${parts.major}.${parts.minor}.${parts.patch}` +} + +function bumpVersion(version, bump) { + const parts = parseSemver(version) + if (bump === "minor") { + parts.minor += 1 + parts.patch = 0 + return formatSemver(parts) + } + parts.patch += 1 + return formatSemver(parts) +} + +function parseCommit(subject, body = "") { + const match = CONVENTIONAL_RE.exec(subject.trim()) + const breaking = + /BREAKING CHANGE/i.test(body) || + (match?.[1] && subject.includes(`${match[1]}!:`)) + + if (!match) { + return { type: "other", scope: null, description: subject.trim(), bump: "patch" } + } + + const type = match[1].toLowerCase() + const scope = match[2]?.slice(1, -1) ?? null + const description = match[3].trim() + let bump = "patch" + + if (breaking || MINOR_TYPES.has(type)) { + bump = "minor" + } else if (PATCH_TYPES.has(type)) { + bump = "patch" + } + + return { type, scope, description, bump } +} + +function getLatestTag() { + try { + const tags = git("tag", "--list", "v*.*.*", "--sort=-v:refname") + const first = tags.split("\n").map((line) => line.trim()).find(Boolean) + return first ?? null + } catch { + return null + } +} + +function getCommitsSince(tag) { + const range = tag ? `${tag}..HEAD` : "HEAD" + const raw = git( + "log", + range, + "--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e", + ) + if (!raw) return [] + + return raw + .split("\x1e") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [sha, shortSha, author, subject, body] = entry.split("\x1f") + const parsed = parseCommit(subject, body) + return { + sha, + shortSha, + author, + subject, + type: parsed.type, + scope: parsed.scope, + bump: parsed.bump, + } + }) +} + +function groupCommits(commits) { + const groups = new Map() + for (const commit of commits) { + const key = commit.type + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(commit) + } + return groups +} + +function renderReleaseNotes({ tag, version, commits }) { + const lines = [`# ${tag}`, "", `Версия **${version}**.`, ""] + const groups = groupCommits(commits) + const order = ["feat", "fix", "chore", "docs", "refactor", "style", "test", "build", "ci", "other"] + + for (const type of order) { + const items = groups.get(type) + if (!items?.length) continue + lines.push(`## ${type}`, "") + for (const item of items) { + lines.push(`- ${item.subject} (\`${item.shortSha}\`, ${item.author})`) + } + lines.push("") + } + + return `${lines.join("\n").trim()}\n` +} + +function buildReleaseUrl(tag) { + if (!repository) return "" + const [owner, repo] = repository.split("/") + if (!owner || !repo) return "" + return `${serverUrl}/${owner}/${repo}/releases/tag/${encodeURIComponent(tag)}` +} + +function writeGithubOutput(values) { + const outputFile = process.env.GITHUB_OUTPUT + if (!outputFile) return + const lines = Object.entries(values) + .map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "%0A")}`) + .join("\n") + writeFileSync(outputFile, `${lines}\n`, { encoding: "utf8", flag: "a" }) +} + +function main() { + const latestTag = getLatestTag() + const baseFromTag = latestTag?.replace(/^v/, "") ?? baseVersion + const commits = latestTag ? getCommitsSince(latestTag) : [] + + if (!latestTag || commits.length === 0) { + mkdirSync(outputDir, { recursive: true }) + writeFileSync(join(outputDir, "release_notes.md"), "", "utf8") + writeFileSync( + join(outputDir, "release-manifest.json"), + `${JSON.stringify( + { + version: baseFromTag, + tag: latestTag ?? `v${baseFromTag}`, + publishedAt: null, + releaseUrl: latestTag ? buildReleaseUrl(latestTag) : "", + commits: [], + }, + null, + 2, + )}\n`, + "utf8", + ) + + writeGithubOutput({ + should_release: "false", + version: baseFromTag, + tag: latestTag ?? `v${baseFromTag}`, + bump: "none", + release_url: latestTag ? buildReleaseUrl(latestTag) : "", + }) + return + } + + const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch" + const version = bumpVersion(baseFromTag, bump) + const tag = `v${version}` + const publishedAt = new Date().toISOString() + const releaseUrl = buildReleaseUrl(tag) + const releaseNotes = renderReleaseNotes({ tag, version, commits }) + const manifest = { + version, + tag, + publishedAt, + releaseUrl, + commits: commits.map((commit) => ({ + sha: commit.sha, + shortSha: commit.shortSha, + subject: commit.subject, + author: commit.author, + type: commit.type, + })), + } + + mkdirSync(outputDir, { recursive: true }) + writeFileSync(join(outputDir, "release_notes.md"), releaseNotes, "utf8") + writeFileSync(join(outputDir, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8") + + writeGithubOutput({ + should_release: "true", + version, + tag, + bump, + release_url: releaseUrl, + }) + + process.stdout.write(`${tag}\n`) +} + +main() diff --git a/.cursor/rules/ma-global.mdc b/.cursor/rules/ma-global.mdc index 2e55ccd..ff64d49 100644 --- a/.cursor/rules/ma-global.mdc +++ b/.cursor/rules/ma-global.mdc @@ -27,3 +27,4 @@ alwaysApply: false ## Связь с проектом - Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют. +- Сообщения коммитов и ожидаемый semver bump — в **`release-versioning.mdc`**. diff --git a/.cursor/rules/ma-implementer.mdc b/.cursor/rules/ma-implementer.mdc index 297cddd..1420e27 100644 --- a/.cursor/rules/ma-implementer.mdc +++ b/.cursor/rules/ma-implementer.mdc @@ -18,6 +18,7 @@ alwaysApply: false - Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция). - Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики). - UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент. +- При завершении задачи предлагать subject коммита по **`release-versioning.mdc`** (`fix:` для patch, `feat:` для minor). ## Формат вывода (строго) diff --git a/.cursor/rules/release-versioning.mdc b/.cursor/rules/release-versioning.mdc new file mode 100644 index 0000000..d9d5217 --- /dev/null +++ b/.cursor/rules/release-versioning.mdc @@ -0,0 +1,32 @@ +--- +description: "Conventional Commits и semver для patch/minor релизов RouterLists" +alwaysApply: true +--- + +# Версионирование релизов + +- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs). +- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`. + +## Patch (`1.0.x`) + +`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:` + +- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов. +- Scope по желанию: `fix(servers):`, `chore(ci):`. + +## Minor (`1.x.0`) + +`feat:`, `feat!`, footer `BREAKING CHANGE` — только для заметного core-функционала. + +## Примеры + +- `fix(servers): исправить опрос API после таймаута` — patch +- `feat(filters): добавить синхронизацию BGP in` — minor +- `chore: мелкие правки` — плохо; `chore(ci): добавить job prepare-release` — хорошо + +## Антипаттерны + +WIP, «update», смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar. + +Подробности — раздел CI/CD в `README.md`. diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index b1ceca7..fd02d24 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -8,9 +8,45 @@ on: env: REGISTRY: git.shts.su + GITEA_SERVER_URL: https://git.shts.su jobs: + prepare-release: + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.release.outputs.should_release }} + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} + bump: ${{ steps.release.outputs.bump }} + release_url: ${{ steps.release.outputs.release_url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute release metadata + id: release + shell: bash + env: + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_SERVER_URL: ${{ env.GITEA_SERVER_URL }} + BASE_VERSION: "1.0.0" + RELEASE_OUTPUT_DIR: .ci/release + run: node .ci/scripts/compute-release.mjs + + - name: Upload release artifacts + if: steps.release.outputs.should_release == 'true' + uses: actions/upload-artifact@v4 + with: + name: release-artifacts + path: | + .ci/release/release_notes.md + .ci/release/release-manifest.json + backend-image: + needs: prepare-release + if: needs.prepare-release.outputs.should_release == 'true' runs-on: ubuntu-latest steps: - name: Checkout @@ -58,24 +94,35 @@ jobs: file: backend/Dockerfile platforms: linux/amd64 push: true + build-args: | + APP_VERSION=${{ needs.prepare-release.outputs.version }} tags: | ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:${{ gitea.sha }} + ${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }} cache-from: type=gha,scope=backend cache-to: type=gha,mode=max,scope=backend labels: | org.opencontainers.image.title=mmapp-backend org.opencontainers.image.description=MikroTik Manager backend (Fastify) - org.opencontainers.image.version=latest + org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }} org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }} frontend-image: + needs: prepare-release + if: needs.prepare-release.outputs.should_release == 'true' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: release-artifacts + path: .ci/release + - name: Configure frontend image name shell: bash run: | @@ -91,7 +138,7 @@ jobs: set -euo pipefail STAGING=".ci/docker/frontend" rm -rf "$STAGING" - mkdir -p "$STAGING/packages/contracts" + mkdir -p "$STAGING/packages/contracts" "$STAGING/public" cp package-lock.json next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs components.json "$STAGING/" node <<'NODE' const fs = require("node:fs") @@ -106,7 +153,7 @@ jobs: cp -R "$dir" "$STAGING/" fi done - mkdir -p "$STAGING/public" + cp .ci/release/release-manifest.json "$STAGING/public/release-manifest.json" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -130,19 +177,24 @@ jobs: NEXT_PUBLIC_BACKEND_URL=same-origin NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live NEXT_PUBLIC_ALLOW_MOCK_DATA=false + NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }} + NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }} tags: | ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:${{ gitea.sha }} + ${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }} cache-from: type=gha,scope=frontend cache-to: type=gha,mode=max,scope=frontend labels: | org.opencontainers.image.title=mmapp-frontend org.opencontainers.image.description=MikroTik Manager frontend (Next.js) - org.opencontainers.image.version=latest + org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }} org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }} updater-image: + needs: prepare-release + if: needs.prepare-release.outputs.should_release == 'true' runs-on: ubuntu-latest steps: - name: Checkout @@ -177,20 +229,103 @@ jobs: tags: | ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:${{ gitea.sha }} + ${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }} cache-from: type=gha,scope=updater cache-to: type=gha,mode=max,scope=updater labels: | org.opencontainers.image.title=mmapp-updater org.opencontainers.image.description=MikroTik Manager autonomous Docker updater - org.opencontainers.image.version=latest + org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }} org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }} - notify-webhook: - if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }} + publish-release: needs: + - prepare-release - backend-image - frontend-image + - updater-image + if: needs.prepare-release.outputs.should_release == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: release-artifacts + path: .ci/release + + - name: Configure git author + shell: bash + run: | + set -euo pipefail + git config user.name "gitea-actions[bot]" + git config user.email "gitea-actions[bot]@users.noreply.gitea" + + - name: Create and push release tag + shell: bash + env: + TAG: ${{ needs.prepare-release.outputs.tag }} + run: | + set -euo pipefail + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping tag push" + exit 0 + fi + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + + - name: Publish Gitea release + shell: bash + env: + TAG: ${{ needs.prepare-release.outputs.tag }} + VERSION: ${{ needs.prepare-release.outputs.version }} + REPOSITORY: ${{ gitea.repository }} + TOKEN: ${{ secrets.ACTIONS_PAT }} + run: | + set -euo pipefail + owner="${REPOSITORY%%/*}" + repo="${REPOSITORY#*/}" + payload=$(node <<'NODE' + const fs = require("node:fs") + const body = fs.readFileSync(".ci/release/release_notes.md", "utf8") + const payload = { + tag_name: process.env.TAG, + target_commitish: "main", + name: process.env.VERSION, + body, + draft: false, + prerelease: false, + } + process.stdout.write(JSON.stringify(payload)) + NODE + ) + status=$(curl --silent --show-error --output /tmp/gitea-release.json --write-out "%{http_code}" \ + --request POST \ + --header "Authorization: token ${TOKEN}" \ + --header "Content-Type: application/json" \ + --data "$payload" \ + "${{ env.GITEA_SERVER_URL }}/api/v1/repos/${owner}/${repo}/releases") + if [ "$status" = "409" ] || [ "$status" = "422" ]; then + echo "Release for ${TAG} already exists (${status}), skipping" + exit 0 + fi + if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then + cat /tmp/gitea-release.json + exit 1 + fi + + notify-webhook: + if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' && needs.prepare-release.outputs.should_release == 'true' }} + needs: + - prepare-release + - backend-image + - frontend-image + - publish-release runs-on: ubuntu-latest steps: - name: Notify deploy webhook @@ -201,5 +336,5 @@ jobs: set -euo pipefail curl --fail --silent --show-error --request POST \ --header "Content-Type: application/json" \ - --data "{\"repository\":\"${{ gitea.repository }}\",\"sha\":\"${{ gitea.sha }}\",\"ref\":\"${{ gitea.ref }}\"}" \ + --data "{\"repository\":\"${{ gitea.repository }}\",\"sha\":\"${{ gitea.sha }}\",\"ref\":\"${{ gitea.ref }}\",\"version\":\"${{ needs.prepare-release.outputs.version }}\",\"tag\":\"${{ needs.prepare-release.outputs.tag }}\",\"releaseUrl\":\"${{ needs.prepare-release.outputs.release_url }}\"}" \ "$WEBHOOK_URL" diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 1542326..860e462 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -12,10 +12,14 @@ ARG BACKEND_INTERNAL_URL=http://backend:8000 ARG NEXT_PUBLIC_BACKEND_URL=same-origin ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false +ARG NEXT_PUBLIC_APP_VERSION=dev +ARG NEXT_PUBLIC_RELEASE_URL= ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL COPY packages/contracts packages/contracts COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./ COPY app app diff --git a/README.md b/README.md index bd2e3b0..ccf65e7 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,18 @@ npm --prefix backend run db:studio Кэш Buildx: `type=gha`, отдельные scope `backend`, `frontend`, `updater`. -Опциональный job **`notify-webhook`**: POST JSON `{"repository","sha","ref"}` на URL из секрета **`DEPLOY_WEBHOOK_URL`** после успешной сборки backend и frontend (updater в `needs` не входит). +Опциональный job **`notify-webhook`**: POST JSON `{"repository","sha","ref","version","tag","releaseUrl"}` на URL из секрета **`DEPLOY_WEBHOOK_URL`** после успешной сборки backend и frontend и публикации релиза (updater в `needs` не входит). + +### Версионирование и релизы + +- Базовая версия: **`v1.0.0`**. Линия semver: `1.x.y` (major `2.x` вне scope). +- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**. +- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`). +- Если после последнего тега нет новых коммитов, релиз и сборка образов **пропускаются**. +- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (markdown notes), образы с тегами `:latest`, `:`, `:`. +- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта). +- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump. +- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc). ## Прод-развёртывание Docker diff --git a/app/(main)/releases/page.tsx b/app/(main)/releases/page.tsx new file mode 100644 index 0000000..451fb68 --- /dev/null +++ b/app/(main)/releases/page.tsx @@ -0,0 +1,93 @@ +import Link from "next/link" +import { PageHeader } from "@/components/page-header" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { buttonVariants } from "@/components/ui/button" +import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version" +import { readReleaseManifest } from "@/lib/release-manifest" +import { cn } from "@/lib/utils" +import { ExternalLinkIcon } from "lucide-react" + +function formatPublishedAt(value: string | null): string { + if (!value) return "Локальная сборка" + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "long", + timeStyle: "short", + }).format(new Date(value)) +} + +export default function ReleasesPage() { + const manifest = readReleaseManifest() + const version = getAppVersion() + const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null + const commits = manifest.commits + + return ( +
+ + + Открыть в Gitea + + ) : null + } + /> + +
+
+ + + Текущая версия + Сборка RouterLists, опубликованная через CI/CD. + + +
+ {formatAppVersionLabel(version)} + {formatPublishedAt(manifest.publishedAt)} +
+

+ Версия формируется автоматически от базы v1.0.0 по Conventional Commits. +

+
+
+ + + + Сводка коммитов + + {commits.length > 0 + ? `Изменения, вошедшие в ${formatAppVersionLabel(manifest.version)}.` + : "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."} + + + + {commits.length === 0 ? ( +

Коммитов для отображения пока нет.

+ ) : ( +
    + {commits.map((commit) => ( +
  • +
    + {commit.type} + {commit.shortSha} + {commit.author} +
    +

    {commit.subject}

    +
  • + ))} +
+ )} +
+
+
+
+
+ ) +} diff --git a/backend/Dockerfile b/backend/Dockerfile index 6e9b22d..8543ed9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,6 +12,8 @@ RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --i && npm rebuild better-sqlite3 FROM deps AS build +ARG APP_VERSION=dev +ENV APP_VERSION=$APP_VERSION COPY packages/contracts packages/contracts COPY backend backend RUN npm run build -w @mmapp/contracts \ @@ -20,9 +22,11 @@ RUN npm run build -w @mmapp/contracts \ FROM node:22-bookworm-slim AS runner WORKDIR /app +ARG APP_VERSION=dev ENV NODE_ENV=production ENV PORT=8000 ENV DATABASE_PATH=/app/data/mikrotik.db +ENV APP_VERSION=$APP_VERSION COPY --from=build /app/package.json /app/package-lock.json ./ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/packages/contracts ./packages/contracts diff --git a/backend/package.json b/backend/package.json index cae30c4..1e0329a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.1.0", + "version": "1.0.0", "description": "MikroTik Manager backend — Fastify + Drizzle + SQLite", "type": "module", "scripts": { @@ -13,7 +13,7 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { - "@mmapp/contracts": "0.1.0", + "@mmapp/contracts": "1.0.0", "@fastify/cors": "^11.2.0", "@fastify/type-provider-zod": "^1.0.0", "better-sqlite3": "^12.9.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index e186b84..d7d3e34 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -47,7 +47,11 @@ await app.register(cors, { // ── routes ───────────────────────────────────────────────────────────────────── -app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() })) +app.get("/health", async () => ({ + status: "ok", + timestamp: new Date().toISOString(), + version: process.env.APP_VERSION ?? "dev", +})) await app.register(serversRoutes, { prefix: "/api/servers" }) await app.register(bgpRoutes, { prefix: "/api" }) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index d62599d..30cd66c 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -47,6 +47,7 @@ import { mockSidebarBadgesByUrl, type SidebarCountsDto, } from "@/lib/sidebar-badges" +import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version" // ─── Command palette trigger button ────────────────────────────────────────── @@ -117,6 +118,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [ items: [ { title: "Оповещения", url: "/alerts", icon: }, { title: "Сбор данных", url: "/data-collection", icon: }, + { title: "Релизы", url: "/releases", icon: }, { title: "Настройки", url: "/settings", icon: }, ], }, @@ -215,6 +217,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }, [mounted, mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot]) const isDark = (resolvedTheme ?? "dark") === "dark" + const appVersionLabel = formatAppVersionLabel(getAppVersion()) return ( @@ -225,7 +228,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
RouterLists - v3.4.1 + {appVersionLabel}
diff --git a/components/command-palette.tsx b/components/command-palette.tsx index 22a5794..9c7cbf1 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -54,6 +54,7 @@ const ALL_ITEMS: CommandItem[] = [ // Система { id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: , keywords: ["alert","notification","уведомление"] }, { id: "data-collection", title: "Сбор данных", group: "Система", url: "/data-collection", icon: , keywords: ["scheduler","планировщик","коллектор","uptime","трафик","журнал","прогон"] }, + { id: "releases", title: "Релизы", group: "Система", url: "/releases", icon: , keywords: ["release","version","версия","changelog","релиз"] }, { id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: , keywords: ["settings","config","конфигурация"] }, ] diff --git a/lib/app-version.ts b/lib/app-version.ts new file mode 100644 index 0000000..c7ffe54 --- /dev/null +++ b/lib/app-version.ts @@ -0,0 +1,16 @@ +import packageJson from "@/package.json" + +const packageVersion = packageJson.version + +export function getAppVersion(): string { + return process.env.NEXT_PUBLIC_APP_VERSION?.trim() || `${packageVersion}-dev` +} + +export function formatAppVersionLabel(version = getAppVersion()): string { + return `v${version}` +} + +export function getReleaseUrl(): string | null { + const value = process.env.NEXT_PUBLIC_RELEASE_URL?.trim() + return value || null +} diff --git a/lib/release-manifest.ts b/lib/release-manifest.ts new file mode 100644 index 0000000..ea4024e --- /dev/null +++ b/lib/release-manifest.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" + +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[] +} + +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 + } +} diff --git a/package-lock.json b/package-lock.json index 23edc8b..988f49b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mmapp", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mmapp", - "version": "0.1.0", + "version": "1.0.0", "hasInstallScript": true, "workspaces": [ "packages/*", @@ -14,7 +14,7 @@ ], "dependencies": { "@base-ui/react": "^1.4.1", - "@mmapp/contracts": "0.1.0", + "@mmapp/contracts": "1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.11.0", @@ -39,11 +39,11 @@ }, "backend": { "name": "mikrotik-manager-backend", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "@fastify/cors": "^11.2.0", "@fastify/type-provider-zod": "^1.0.0", - "@mmapp/contracts": "0.1.0", + "@mmapp/contracts": "1.0.0", "better-sqlite3": "^12.9.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.45.2", @@ -12670,10 +12670,25 @@ }, "packages/contracts": { "name": "@mmapp/contracts", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "zod": "^4.4.1" } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/package.json b/package.json index ed1c7a9..17c1261 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmapp", - "version": "0.1.0", + "version": "1.0.0", "private": true, "workspaces": [ "packages/*", @@ -14,7 +14,7 @@ "lint": "eslint" }, "dependencies": { - "@mmapp/contracts": "0.1.0", + "@mmapp/contracts": "1.0.0", "@base-ui/react": "^1.4.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 7830d96..14836e9 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@mmapp/contracts", - "version": "0.1.0", + "version": "1.0.0", "private": true, "type": "module", "files": [ diff --git a/public/release-manifest.json b/public/release-manifest.json new file mode 100644 index 0000000..3e9d2ee --- /dev/null +++ b/public/release-manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "tag": "v1.0.0", + "publishedAt": null, + "releaseUrl": "", + "commits": [] +}