feat: update application versioning and enhance release management
Docker images / prepare-release (push) Successful in 38s
Docker images / backend-image (push) Has been skipped
Docker images / frontend-image (push) Has been skipped
Docker images / updater-image (push) Has been skipped
Docker images / publish-release (push) Has been skipped
Docker images / notify-webhook (push) Has been skipped
Docker images / prepare-release (push) Successful in 38s
Docker images / backend-image (push) Has been skipped
Docker images / frontend-image (push) Has been skipped
Docker images / updater-image (push) Has been skipped
Docker images / publish-release (push) Has been skipped
Docker images / notify-webhook (push) Has been skipped
- 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.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"tag": "v1.0.0",
|
||||
"publishedAt": null,
|
||||
"releaseUrl": "",
|
||||
"commits": []
|
||||
}
|
||||
@@ -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()
|
||||
@@ -27,3 +27,4 @@ alwaysApply: false
|
||||
## Связь с проектом
|
||||
|
||||
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
||||
- Сообщения коммитов и ожидаемый semver bump — в **`release-versioning.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).
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
+143
-8
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`, `:<sha>`, `:<semver>`.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
||||
actions={
|
||||
releaseUrl ? (
|
||||
<Link
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Открыть в Gitea
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-[960px] mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Текущая версия</CardTitle>
|
||||
<CardDescription>Сборка RouterLists, опубликованная через CI/CD.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
||||
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Сводка коммитов</CardTitle>
|
||||
<CardDescription>
|
||||
{commits.length > 0
|
||||
? `Изменения, вошедшие в ${formatAppVersionLabel(manifest.version)}.`
|
||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{commits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{commits.map((commit) => (
|
||||
<li key={commit.sha} className="rounded-md border px-3 py-2">
|
||||
<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">{commit.shortSha}</span>
|
||||
<span>{commit.author}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{commit.subject}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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: <BellIcon /> },
|
||||
{ title: "Сбор данных", url: "/data-collection", icon: <DatabaseIcon /> },
|
||||
{ title: "Релизы", url: "/releases", icon: <BadgeCheckIcon /> },
|
||||
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
|
||||
],
|
||||
},
|
||||
@@ -215,6 +217,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}, [mounted, mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
|
||||
|
||||
const isDark = (resolvedTheme ?? "dark") === "dark"
|
||||
const appVersionLabel = formatAppVersionLabel(getAppVersion())
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@@ -225,7 +228,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</div>
|
||||
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">RouterLists</span>
|
||||
<span className="text-[10px] font-mono text-sidebar-foreground/40">v3.4.1</span>
|
||||
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CommandPaletteButton />
|
||||
|
||||
@@ -54,6 +54,7 @@ const ALL_ITEMS: CommandItem[] = [
|
||||
// Система
|
||||
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: <BellIcon />, keywords: ["alert","notification","уведомление"] },
|
||||
{ id: "data-collection", title: "Сбор данных", group: "Система", url: "/data-collection", icon: <DatabaseIcon />, keywords: ["scheduler","планировщик","коллектор","uptime","трафик","журнал","прогон"] },
|
||||
{ id: "releases", title: "Релизы", group: "Система", url: "/releases", icon: <BadgeCheckIcon />, keywords: ["release","version","версия","changelog","релиз"] },
|
||||
{ id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: <SettingsIcon />, keywords: ["settings","config","конфигурация"] },
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
+21
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mmapp/contracts",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"tag": "v1.0.0",
|
||||
"publishedAt": null,
|
||||
"releaseUrl": "",
|
||||
"commits": []
|
||||
}
|
||||
Reference in New Issue
Block a user