ReleaseNotes düzeltmesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-13 13:43:48 +03:00
parent ee121db521
commit cced417ede
10 changed files with 194 additions and 68 deletions

6
.gitignore vendored
View file

@ -2,4 +2,8 @@ configs/**/data/**
*.bak
**/node_modules
**/.DS_Store
logs/
logs/
# Build sırasında git tag'lerinden üretilir (api/scripts/generate-release-notes.sh).
# Takip edilirse deploy'daki `git pull` yerel değişiklikle çakışır ve dosya eskir.
api/src/Sozsoft.Platform.HttpApi.Host/release-notes.json

View file

@ -2,26 +2,29 @@
set -e
# Sürüm notlarını git tag'lerinden üretir:
# api/release-notes/release-notes.json
# api/src/Sozsoft.Platform.HttpApi.Host/release-notes.json
#
# Bu dosya API konteynerine /etc/api/release-notes altına MOUNT edilir (imaja
# GÖMÜLMEZ). Uygulama açılışında ReleaseNoteStartupService onu `Sas_H_ReleaseNote`
# tablosuyla eşitler. Yani yeni bir sürüm tag'i için ne DbMigrator ne de imaj
# derlemesi gerekir; deploy sırasında bu script koşar ve konteyner yeniden
# başlar. İstemci sürüm numarasını ve changelog'u application-configuration
# yanıtındaki `extraProperties.changeLogs` alanından okur.
# Dosya csproj'daki `Content Update="release-notes.json"` kuralıyla publish
# çıktısına kopyalanır, yani imaja gömülür ve konteynerde ContentRootPath
# altında (/srv/app) yer alır. ReleaseNoteStartupService açılışta tam bu yolu
# okuyup `Sas_H_ReleaseNote` tablosuyla eşitler; DbMigrator gerekmez.
#
# Kaynak ağacının DIŞINDA bir klasöre yazılmasının sebebi Docker katman cache'i:
# dosya `src/` altında olsaydı Dockerfile'daki `COPY . .` her tag'de geçersizleşip
# tüm `dotnet publish` adımını yeniden çalıştırırdı.
# ÖNEMLİ: Çıktı yolu ReleaseNoteStartupService'in okuduğu yolla aynı olmak
# ZORUNDA. Daha önce script `api/release-notes/` altına yazıyordu; orası ne
# publish çıktısına kopyalanıyor ne de konteynere mount ediliyordu, dolayısıyla
# imajdaki eski (commit'lenmiş) dosya kullanılıyor ve yeni tag'ler hiç
# görünmüyordu.
#
# Deploy akışında deploy/app.sh tarafından otomatik çağrılır; elle de
# çalıştırılabilir. Çıktı git'e commit EDİLMEZ (.gitignore).
# Çıktı .gitignore'dadır: takip edilseydi deploy'daki `git pull` her seferinde
# yerel değişiklikle çakışırdı.
#
# Deploy akışında configs/deployment/scripts/build/api.sh tarafından otomatik
# çağrılır; elle de çalıştırılabilir.
#
# Dış bağımlılığı yoktur (jq/node gerekmez): JSON kaçışları sed ile yapılır.
cd "$(dirname "$0")/.."
OUTPUT="release-notes/release-notes.json"
OUTPUT="src/Sozsoft.Platform.HttpApi.Host/release-notes.json"
mkdir -p "$(dirname "$OUTPUT")"
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then

View file

@ -108,8 +108,12 @@ public class ReleaseNoteStartupService : IHostedService
using var scope = _serviceProvider.CreateScope();
var (inserted, updated, deleted) = await SyncItemsAsync(scope.ServiceProvider, items);
// En son sürüm de loglanır: dosya eskiyse (üretim adımı atlanmış
// veya yanlış yola yazılmışsa) "0 eklendi" tek başına yanıltıcı olur.
_logger.LogInformation(
"Sürüm notları senkronize edildi: {Total} tag, {Inserted} eklendi, {Updated} güncellendi, {Deleted} silindi.",
"Sürüm notları senkronize edildi ({Path}, en son sürüm {Latest}): {Total} tag, {Inserted} eklendi, {Updated} güncellendi, {Deleted} silindi.",
path,
items[0].Version,
items.Count,
inserted,
updated,

View file

@ -1,15 +1,25 @@
{
"ReleaseNotes": [
{
"version": "1.2.0",
"version": "1.2.1",
"releaseDate": "2026-08-13",
"commit": "798acbef9e6a26e29e58f10b13cb8e63ac5fa6b4",
"commit": "ee121db5211ffbd28496b87ce9e4776ff96f7640",
"notes": [
"Claude.ai optimizasyon güncellemesi",
"Zaafiyet testleri ve güncellemeleri",
"React 19 upgrade",
"Devexpress 26.1.4",
"Migrations ve Seeds klasör düzenlemesi",
"New Version Check komponent düzenlemesi"
]
},
{
"version": "1.1.11",
"releaseDate": "2026-08-11",
"commit": "783c1fe1d35f7cb8f23fb63fad26123cbe54aafc",
"notes": [
"Claude.ai optimizasyon güncellemesi"
]
},
{
"version": "1.1.10",
"releaseDate": "2026-07-31",

View file

@ -1,3 +1,9 @@
#!/bin/bash
# set -e: sürüm notu üretimi sessizce başarısız olursa imaj eski/eksik notlarla
# derlenmesin. Daha önce böyle olmuştu: dosya git'te takip edildiği için hata
# fark edilmiyordu.
set -e
cd ~/sozsoft-platform
git checkout main
# --tags --force: sürüm notları git tag'lerinden üretiliyor; yerel tag origin'den
@ -8,7 +14,19 @@ cd ~/sozsoft-platform/api
# Sürüm notları git tag'lerinden üretilir ve API imajına gömülür; uygulama
# açılışta bunları veritabanına senkronize eder (DbMigrator gerekmez).
./scripts/generate-release-notes.sh
# `bash ...` ile çağrılır: depo Windows'tan yönetildiği için çalıştırma bitinin
# kaybolması "Permission denied" verip sürüm notlarını sessizce düşürüyordu.
RELEASE_NOTES=src/Sozsoft.Platform.HttpApi.Host/release-notes.json
bash scripts/generate-release-notes.sh
# Dosya publish çıktısına csproj üzerinden girer; yoksa imaj sürüm notsuz kalır
# ve API açılışta "release-notes.json bulunamadı" uyarısı verir. Sessizce devam
# etmek yerine burada dur.
if [ ! -s "$RELEASE_NOTES" ]; then
echo "HATA: ${RELEASE_NOTES} üretilemedi, imaj derlenmiyor." >&2
exit 1
fi
VERSION=$(cat ~/sozsoft-platform/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.json \
| grep -o '"[^"]*"\s*:\s*"[^"]*"' \
| grep -E '^"(Version)"' \

View file

@ -11,6 +11,7 @@ import { Link } from 'react-router-dom'
import { Avatar } from '../ui'
import {
FcEngineering,
FcNews,
FcNext,
FcNfcSign,
FcSynchronize,
@ -142,6 +143,18 @@ const UserDropdownBase = ({ className }: CommonProps) => {
</span>
</Dropdown.Item>
<Dropdown.Item eventKey="ChangeLog" className="mb-1 px-0">
<Link
className="flex h-full w-full items-center gap-2 px-2"
to={ROUTES_ENUM.protected.admin.changeLog}
>
<span className="text-xl opacity-50">
<FcNews />
</span>
<span>{translate('::App.ChangeLog')}</span>
</Link>
</Dropdown.Item>
{/* <Dropdown.Item variant="divider" /> */}
<Dropdown.Item eventKey="Sign Out" className="mb-1 px-2" onClick={signOut}>
<span className="text-xl opacity-50">

View file

@ -7,6 +7,8 @@ export interface LocaleStoreModel {
currentLang: string
currentUiVersion: string | undefined
currentTenantName?: string
/** Dolu ise kullanıcı bir sonraki girişinde ChangeLog ekranına yönlendirilir. */
pendingChangelogVersion?: string
}
export interface LocaleStoreActions {
@ -14,6 +16,7 @@ export interface LocaleStoreActions {
onSetLang: ThunkOn<LocaleModel, Injections, StoreModel>
setUiVersion: Action<LocaleStoreModel, string | undefined>
setTenantName: Action<LocaleStoreModel, string | undefined>
clearPendingChangelog: Action<LocaleStoreModel, void>
}
export type LocaleModel = LocaleStoreModel & LocaleStoreActions
@ -22,6 +25,7 @@ const initialState: LocaleStoreModel = {
currentLang: appConfig.locale,
currentUiVersion: appConfig.uiVersion,
currentTenantName: undefined,
pendingChangelogVersion: undefined,
}
export const localeModel: LocaleModel = {
@ -36,10 +40,41 @@ export const localeModel: LocaleModel = {
helpers.getStoreActions().abpConfig.getConfig(false)
},
),
/**
* Backend'den gelen çalışan sürümü işler (application-configuration
* `extraProperties.changeLogs.uiVersion`).
*
* Sürüm son görülenden farklıysa changelog bayrağı yazılır; kullanıcı bir
* sonraki girişinde ChangeLog ekranına yönlendirilir. Tetikleyici bilerek
* bundle değil sürümdür: yalnızca yeni bir git tag'i eklendiğinde de kullanıcı
* değişiklikleri görür.
*
* Referans ayrı bir alan değil `currentUiVersion`'ın kendisidir; zaten persist
* edildiği için "son görülen sürüm" bilgisini o taşır. Ayrı bir alan eklemek
* mevcut kullanıcılarda bir sürümlük boşluk yaratırdı (yeni alan boş olduğu
* için ilk deploy sessizce atlanır).
*
* Yeni tarayıcıda `currentUiVersion` boştur (`appConfig.uiVersion = undefined`);
* kullanıcı ilk girişinde changelog'a düşmesin diye bayrak yazılmaz.
*
* Durum bu model üzerinden persist edilir (`[EasyPeasyStore][0].locale`);
* ayrı `localStorage` anahtarı KULLANILMAZ.
*/
setUiVersion: action((state, payload) => {
// Config henüz yüklenmediğinde çağrı boş gelir; persist edilmiş "son görülen
// sürüm" bu yüzden ezilmemeli, yoksa her açılışta referans sıfırlanır ve
// bayrak bir daha hiç yazılmaz.
if (!payload) return
const lastSeen = state.currentUiVersion
state.currentUiVersion = payload
if (lastSeen && lastSeen !== payload) state.pendingChangelogVersion = payload
}),
setTenantName: action((state, payload) => {
state.currentTenantName = payload
}),
clearPendingChangelog: action((state) => {
state.pendingChangelogVersion = undefined
}),
}

View file

@ -1,7 +1,7 @@
import { jwtDecode } from 'jwt-decode'
import { useNavigate } from 'react-router-dom'
import useQuery from './useQuery'
import { useStoreActions, useStoreState } from '../../store/store'
import { store, useStoreActions, useStoreState } from '../../store/store'
import {
FailedSignInResponse,
SignInCredential,
@ -13,7 +13,6 @@ import { isLoginSuccess } from '../../proxy/account/models'
import { REDIRECT_URL_KEY } from '../../constants/app.constant'
import appConfig from '../../proxy/configs/app.config'
import { register } from '../../services/account.service'
import { hasPendingChangelog } from '@/views/version/swRegistration'
import { ROUTES_ENUM } from '@/routes/route.constant'
type Status = 'success' | 'failed' | 'error'
@ -72,7 +71,11 @@ function useAuth() {
const redirectUrl = query.get(REDIRECT_URL_KEY)
// Uygulama yeni bir sürümle/deploy ile açıldıysa kullanıcı önce
// değişiklikleri görsün. Bayrak ChangeLog ekranında tüketilir.
const target = hasPendingChangelog()
//
// Değer hook state'inden değil doğrudan store'dan okunur: bayrak giriş
// sayfasındaki config yüklemesiyle yazılıyor, render kapanışı bayat
// kalabilir.
const target = store.getState().locale.pendingChangelogVersion
? ROUTES_ENUM.protected.admin.changeLog
: (redirectUrl ?? appConfig.authenticatedEntryPath)
navigate(target)

View file

@ -6,7 +6,8 @@ import React, { useEffect, useState } from 'react'
import PageTitle from '@/components/shared/PageTitle'
import { FaRegCheckCircle, FaSyncAlt, FaTag } from 'react-icons/fa'
import { releaseNoteService, type ReleaseNoteDto } from '@/services/releaseNote.service'
import { checkForAppUpdate, clearPendingChangelog } from './swRegistration'
import { checkForAppUpdate } from './swRegistration'
import { useStoreActions } from '@/store'
// User dropdown reuses the exact update-check flow owned by this component.
// eslint-disable-next-line react-refresh/only-export-components
@ -106,6 +107,7 @@ const Log = ({
const Changelog = () => {
const { translate } = useLocalization()
const { isCheckingUpdate, checkForUpdate } = useManualUpdateCheck()
const clearPendingChangelog = useStoreActions((actions) => actions.locale.clearPendingChangelog)
// Kaynak `ReleaseNote` entity'sidir; liste yalnızca bu ekran açıldığında
// istenir (application-configuration sadece çalışan sürümü taşır).
@ -120,7 +122,7 @@ const Changelog = () => {
.getList()
.then(setReleases)
.catch((error: unknown) => console.warn('Sürüm notları alınamadı.', error))
}, [])
}, [clearPendingChangelog])
return (
<Container>

View file

@ -19,10 +19,11 @@ import './installPrompt'
* 2. Yeni worker bulunursa kurulum başlar; `src/sw.ts` indirdiği her dosyayı
* isim/boyut/yüzde olarak yayınlar, bu modül state'e çevirir.
* 3. Kurulum bitince SKIP_WAITING `controllerchange` reload.
* 4. Backend'den gelen sürüm son görülenden farklıysa changelog bayrağı
* yazılır; login sonrası kullanıcı ChangeLog ekranına yönlendirilir.
* Tetikleyici sürümdür, bundle değil: yalnızca yeni bir sürüm notu (git
* tag) eklendiğinde de kullanıcı değişiklikleri görür.
* 4. Changelog yönlendirmesi bu modülün işi DEĞİLDİR: son görülen sürüm ve
* bekleyen changelog bayrağı store'un `locale` diliminde tutulur
* (`setUiVersion` / `pendingChangelogVersion`), böylece durum tek yerde ve
* `[EasyPeasyStore][0]` içinde persist edilir. Bu modül yalnızca güncelleme
* penceresinin gösterdiği sürüm/not bilgisini tutar.
*
* Bu dosya entry chunk'ındadır: React/UI/store bağımlılığı yoktur, pencere
* yalnızca gerçekten indirme varken lazy yüklenir.
@ -111,14 +112,22 @@ const VERSION_POLL_INTERVAL = 30 * 60 * 1000
const MIN_CHECK_GAP = 30 * 1000
const ACTIVATION_RETRY_DELAY = 8_000
const ACTIVATION_TIMEOUT = 45_000
/** İndirme bittikten sonra geçişin kendiliğinden başlaması için tanınan süre. */
const ACTIVATION_HANDOFF_TIMEOUT = 15_000
const REGISTRATION_TIMEOUT = 10_000
const UPDATE_CALL_TIMEOUT = 20_000
/** Kurulum/aktivasyon başarısız olduğunda otomatik yeniden deneme süresi. */
const RETRY_DELAY_SECONDS = 15
const LAST_VERSION_KEY = 'sozsoft:last-version'
const PENDING_CHANGELOG_KEY = 'sozsoft:pending-changelog'
const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload'
/**
* Chunk yeniden yükleme koruması: sonsuz reload döngüsünü keser.
*
* Store'da tutulamaz — reload'ın hemen öncesinde, React/store mount edilmeden
* yazılması gerekiyor. Bu yüzden modüldeki tek doğrudan depo anahtarı budur;
* sürüm/changelog durumu store'un `locale` diliminde durur. Bayrak 10 saniye
* sonra silindiği için kalıcı bir kayıt bırakmaz.
*/
const CHUNK_RELOAD_GUARD_KEY = 'chunkReloadGuard'
/** Service worker yalnızca prod'da, dev'de ise VITE_PWA_DEV=true ile aktiftir. */
const isServiceWorkerEnabled = import.meta.env.PROD || import.meta.env.VITE_PWA_DEV === 'true'
@ -129,19 +138,18 @@ const withTimeout = <T>(promise: Promise<T>, ms: number) =>
new Promise<undefined>((resolve) => window.setTimeout(() => resolve(undefined), ms)),
])
const readStorage = (key: string, session = false) => {
const readStorage = (key: string) => {
try {
return (session ? sessionStorage : localStorage).getItem(key) ?? undefined
return localStorage.getItem(key) ?? undefined
} catch {
return undefined
}
}
const writeStorage = (key: string, value: string | undefined, session = false) => {
const writeStorage = (key: string, value: string | undefined) => {
try {
const store = session ? sessionStorage : localStorage
if (value === undefined) store.removeItem(key)
else store.setItem(key, value)
if (value === undefined) localStorage.removeItem(key)
else localStorage.setItem(key, value)
} catch {
/* storage kapalıysa yoksay */
}
@ -240,9 +248,13 @@ function recalculate(
/* ------------------------------------------------------------------ */
/**
* Sürüm bilgisini application-configuration yanıtından alır.
* Sürüm bilgisini application-configuration yanıtından alır ve yalnızca bu
* modülün GÖRÜNTÜLEME durumunu (güncelleme penceresi) besler.
*
* Kalıcı sürüm takibi son görülen sürüm ve changelog bayrağı burada değil,
* store'un `locale` diliminde tutulur (`setUiVersion`). Bu modül entry chunk'ta
* olduğu için store'a bağımlı olmamalı; bayrağı yazan taraf store'dur.
*
* Store, config'i her yüklediğinde bunu çırır; ayrı bir sürüm isteği yoktur.
* Yük şekli: `extraProperties.changeLogs = { uiVersion, tags[] }`.
*/
export const setAppVersionInfo = (raw: unknown) => {
@ -253,29 +265,6 @@ export const setAppVersionInfo = (raw: unknown) => {
currentVersion: data.uiVersion,
changeLog: Array.isArray(data.tags) ? data.tags.filter(Boolean) : [],
})
markVersionSeen(data.uiVersion)
}
/** Login sonrası changelog yönlendirmesi için bayrak. */
export const hasPendingChangelog = () => !!readStorage(PENDING_CHANGELOG_KEY)
export const clearPendingChangelog = () => writeStorage(PENDING_CHANGELOG_KEY, undefined)
/**
* Sürüm numarası son görülenden farklıysa changelog bayrağını yazar; kullanıcı
* bir sonraki girişinde ChangeLog ekranına yönlendirilir.
*
* Tetikleyici bilerek BUNDLE DEĞİL, backend'den gelen sürümdür: yalnızca yeni
* bir sürüm notu (git tag) eklendiğinde de hiçbir bileşen değişmemiş olsa
* bile kullanıcı değişiklikleri görür. İlk ılışta (kayıt yokken) bayrak
* yazılmaz, yalnızca mevcut sürüm not edilir.
*/
function markVersionSeen(version: string) {
const lastSeen = readStorage(LAST_VERSION_KEY)
if (lastSeen === version) return
if (lastSeen) writeStorage(PENDING_CHANGELOG_KEY, version)
writeStorage(LAST_VERSION_KEY, version)
}
/* ------------------------------------------------------------------ */
@ -291,6 +280,7 @@ let lastCheckAt = 0
let pollTimer: number | undefined
let activationRetryTimer: number | undefined
let activationTimeoutTimer: number | undefined
let activationHandoffTimer: number | undefined
const watchedRegistrations = new WeakSet<ServiceWorkerRegistration>()
const watchedWorkers = new WeakSet<ServiceWorker>()
@ -298,8 +288,39 @@ const watchedWorkers = new WeakSet<ServiceWorker>()
function clearActivationTimers() {
window.clearTimeout(activationRetryTimer)
window.clearTimeout(activationTimeoutTimer)
window.clearTimeout(activationHandoffTimer)
activationRetryTimer = undefined
activationTimeoutTimer = undefined
activationHandoffTimer = undefined
}
/**
* İndirme bitti ('ready') ama geçişi kimse başlatmadıysa devreye girer.
*
* Normal akışta geçişi worker'ın `installed` olayı tetikler. O olay kaçabiliyor:
* dinleyici yalnızca `reg.installing` görüldüğünde kuruluyor ve sayfa hard reload
* ile ıldığında kayıt zaten `waiting` durumunda olabiliyor. Watchdog olmadan
* durum sonsuza kadar 'ready'de kalıyordu ekran "yeni sürüme geçiliyor"da
* donuyor, zaman ımı da yoktu çünkü sayaçlar yalnızca aktivasyon başladıktan
* sonra kuruluyor.
*/
function scheduleActivationHandoff() {
if (activationHandoffTimer || activating || reloading) return
activationHandoffTimer = window.setTimeout(() => {
activationHandoffTimer = undefined
if (activating || reloading) return
if (registration?.waiting) {
activateWaitingWorker()
return
}
// Bekleyen worker yok: geçiş sessizce kaybolmuş. Kabuk cache'i tazelenerek
// yenilenir, aksi halde aynı eski index.html geri gelir.
console.warn('Yeni sürüme geçiş tetiklenmedi, sayfa yenileniyor.')
void reloadWithFreshShell()
}, ACTIVATION_HANDOFF_TIMEOUT)
}
function reloadForUpdate() {
@ -539,6 +560,12 @@ function handleWorkerMessage(event: MessageEvent) {
*/
const transitions = controlled && data.phase !== 'warmup'
// Isıtma kurulumla aynı anda koşabiliyor (eski aktif worker ısıtırken yenisi
// kuruluyor). İki oturumun dosya listeleri ayrı index uzayında olduğu için
// birleşince satırlar tekrarlanıyor ve toplam boyut aşıyordu; geçiş sürecinde
// ısıtma mesajları yok sayılır.
if (data.phase === 'warmup' && (activating || state.status === 'ready')) return
const totals = { loadedBytes: data.loadedBytes, totalBytes: data.totalBytes }
switch (data.type) {
@ -575,7 +602,8 @@ function handleWorkerMessage(event: MessageEvent) {
visible: true,
percent: 100,
})
if (!transitions) hideDialogLater()
if (transitions) scheduleActivationHandoff()
else hideDialogLater()
break
case 'SW_PRECACHE_ERROR':
if (!transitions) {
@ -714,8 +742,14 @@ function scheduleWarmup(reg: ServiceWorkerRegistration) {
function trackInstallingWorker(reg: ServiceWorkerRegistration) {
const worker = reg.installing
// Controller yoksa bu ilk kurulumdur; kullanıcıya güncelleme demek yanlış olur.
if (!worker || !navigator.serviceWorker.controller || watchedWorkers.has(worker)) return
if (!worker || watchedWorkers.has(worker)) return
// İlk kurulumda kullanıcıya "güncelleme" demek yanlış olur. Ölçüt yalnızca
// `controller` olamaz: hard reload'da sayfa kontrolsüz açılabiliyor, ama ortada
// güncellenen bir kayıt (aktif worker) var. Bu ayrım yapılmazsa dinleyici hiç
// kurulmuyor ve geçişi tetikleyecek `installed` olayı kaçıyordu.
const isUpdate = !!navigator.serviceWorker.controller || !!reg.active
if (!isUpdate) return
watchedWorkers.add(worker)
void showUpdateDialog('downloading')
@ -776,8 +810,8 @@ function startWatchers() {
function handleChunkLoadFailures() {
window.addEventListener('vite:preloadError', (event) => {
if (reloading) return
if (readStorage(CHUNK_RELOAD_GUARD_KEY, true)) return
writeStorage(CHUNK_RELOAD_GUARD_KEY, '1', true)
if (readStorage(CHUNK_RELOAD_GUARD_KEY)) return
writeStorage(CHUNK_RELOAD_GUARD_KEY, '1')
event.preventDefault()
console.warn('Eksik chunk tespit edildi, sayfa yenileniyor.')
reloading = true
@ -785,7 +819,7 @@ function handleChunkLoadFailures() {
})
// Sayfa sorunsuz açıldıysa koruma bayrağını temizle.
window.setTimeout(() => writeStorage(CHUNK_RELOAD_GUARD_KEY, undefined, true), 10_000)
window.setTimeout(() => writeStorage(CHUNK_RELOAD_GUARD_KEY, undefined), 10_000)
}
/** Kapalıyken önceki oturumlardan kalan kayıtları temizler. */