feat: expose deploy version (git SHA) on CMS and health endpoint
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions

'Is the fix live?' becomes a single check instead of an SSH session:

- CMS: git short SHA read at startup, shown in the bottom bar (v<sha>),
  served by the public GET /version endpoint, recorded in a startup audit
  entry
- Website: deploy.sh exports DEPLOY_VERSION (git SHA), Dockerfile bakes it
  via build ARG into the runtime env, /api/health reports it as
  deployVersion, smoke test asserts a non-'unversioned' stamp

Closes MITHOME-63
This commit is contained in:
Do Siki
2026-08-18 21:19:43 +02:00
parent edd2e46ae3
commit 3a5a09c361
10 changed files with 126 additions and 5 deletions
+22 -3
View File
@@ -11,7 +11,7 @@
const http = require('http'); const http = require('http');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { exec } = require('child_process'); const { exec, execSync } = require('child_process');
const crypto = require('crypto'); const crypto = require('crypto');
const { validateContent } = require('./proto/src/content/schema'); const { validateContent } = require('./proto/src/content/schema');
const { renderMarkdown } = require('./scripts/markdown-render'); const { renderMarkdown } = require('./scripts/markdown-render');
@@ -133,6 +133,17 @@ function backupAndWriteAtomically(targetFile, data, backupDir = BACKUP_DIR) {
return backupFile; return backupFile;
} }
// Deploy version = git short SHA of the checked-out commit. Read once at startup:
// a CMS "deploy" is git pull + service restart, so this identifies the running code.
function readDeployVersion() {
try {
return execSync('git rev-parse --short HEAD', { cwd: __dirname, encoding: 'utf8' }).trim();
} catch {
return 'unknown';
}
}
const DEPLOY_VERSION = readDeployVersion();
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
const clientAddress = getClientAddress(req); const clientAddress = getClientAddress(req);
if (!securityConfigIsValid()) { if (!securityConfigIsValid()) {
@@ -167,6 +178,13 @@ const server = http.createServer(async (req, res) => {
return; return;
} }
// Public: deploy version (git SHA only — no secrets) for quick "is the fix live?" checks.
if (req.method === 'GET' && u.pathname === '/version') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV }));
return;
}
// Public: styled login page (shown after logout and for unauthenticated browser visits). // Public: styled login page (shown after logout and for unauthenticated browser visits).
if (req.method === 'GET' && u.pathname === '/login') { if (req.method === 'GET' && u.pathname === '/login') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
@@ -366,7 +384,7 @@ const server = http.createServer(async (req, res) => {
const contentHash = crypto.createHash('sha256').update(jsonData).digest('hex'); const contentHash = crypto.createHash('sha256').update(jsonData).digest('hex');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash)); res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash, DEPLOY_VERSION));
}); });
if (require.main === module) { if (require.main === module) {
@@ -374,7 +392,8 @@ if (require.main === module) {
throw new Error('CMS_USER, CMS_PASS és érvényes CMS_DEPLOY_ENV nélkül a Content Editor nem indítható el.'); throw new Error('CMS_USER, CMS_PASS és érvényes CMS_DEPLOY_ENV nélkül a Content Editor nem indítható el.');
} }
server.listen(PORT, '127.0.0.1', () => { server.listen(PORT, '127.0.0.1', () => {
console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT}\n`); writeAudit('startup', { version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV });
console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT} (v${DEPLOY_VERSION})\n`);
console.log(' Szerkeszthető fájlok:'); console.log(' Szerkeszthető fájlok:');
Object.entries(FILE_LABELS).forEach(([k, l]) => { Object.entries(FILE_LABELS).forEach(([k, l]) => {
const rel = k === 'common' ? 'common.json' : `pages/${k}.json`; const rel = k === 'common' ? 'common.json' : `pages/${k}.json`;
+4
View File
@@ -28,6 +28,10 @@ echo "🚀 Deploy indítása: [$ENV] környezet (${COMPOSE_FILE})"
echo "📦 Kód frissítése a main ágról..." echo "📦 Kód frissítése a main ágról..."
git pull origin main git pull origin main
# Deploy version = git short SHA; baked into the image and served via /api/health
# so "which build is live?" is a single curl away.
export DEPLOY_VERSION="$(git rev-parse --short HEAD)"
# 2. Környezeti változók (.env.<env>) # 2. Környezeti változók (.env.<env>)
ENV_FILE=".env.${ENV}" ENV_FILE=".env.${ENV}"
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
+1
View File
@@ -7,6 +7,7 @@ services:
target: runner # Use runner stage for production (smaller size, no dev dependencies) target: runner # Use runner stage for production (smaller size, no dev dependencies)
args: args:
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://mozdit.hu} - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://mozdit.hu}
- DEPLOY_VERSION=${DEPLOY_VERSION:-unversioned}
container_name: mozdit-app-prod container_name: mozdit-app-prod
ports: ports:
- "8080:3000" # Host port 8080 elkerüli a lokális npm dev (3000) összeakadást - "8080:3000" # Host port 8080 elkerüli a lokális npm dev (3000) összeakadást
+1
View File
@@ -8,6 +8,7 @@ services:
args: args:
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://stage.mozdit.hu} - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://stage.mozdit.hu}
- NEXT_PUBLIC_DEPLOY_ENV=staging - NEXT_PUBLIC_DEPLOY_ENV=staging
- DEPLOY_VERSION=${DEPLOY_VERSION:-unversioned}
container_name: mozdit-app-staging container_name: mozdit-app-staging
ports: ports:
- "127.0.0.1:8081:3000" # Belső port — csak nginx-en keresztül elérhető - "127.0.0.1:8081:3000" # Belső port — csak nginx-en keresztül elérhető
+1 -1
View File
@@ -45,7 +45,7 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció
- **Fájl fülek** (felül): oldalankénti tartalom — Kezdőlap, Rólunk, Szolgáltatások, Kapcsolat, jogi oldalak, közös szövegek. - **Fájl fülek** (felül): oldalankénti tartalom — Kezdőlap, Rólunk, Szolgáltatások, Kapcsolat, jogi oldalak, közös szövegek.
- **Szerkesztőfelület**: a kiválasztott oldal összes szerkeszthető mezője. - **Szerkesztőfelület**: a kiválasztott oldal összes szerkeszthető mezője.
- **Alsó sáv**: 💾 Mentés, 🚀 Publikálás, 🔗 Előnézet, ❓ Súgó, 🚪 Kilépés. - **Alsó sáv**: 💾 Mentés, 🚀 Publikálás, 🔗 Előnézet, ❓ Súgó, 🚪 Kilépés, valamint a **futó verzió** (pl. `va7b1a2c`) — ha a fejlesztő megkér, hogy ellenőrizd a verziót, ezt a jelölést mondd neki.
### Szöveg szerkesztése ### Szöveg szerkesztése
+4
View File
@@ -10,6 +10,10 @@ ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_DEPLOY_ENV ARG NEXT_PUBLIC_DEPLOY_ENV
ENV NEXT_PUBLIC_DEPLOY_ENV=$NEXT_PUBLIC_DEPLOY_ENV ENV NEXT_PUBLIC_DEPLOY_ENV=$NEXT_PUBLIC_DEPLOY_ENV
# Runtime-only deploy version (git SHA passed by deploy.sh) — exposed via /api/health.
ARG DEPLOY_VERSION
ENV DEPLOY_VERSION=$DEPLOY_VERSION
# Copy package files # Copy package files
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
+4
View File
@@ -4,7 +4,11 @@ test('SMOKE-01: health endpoint is available', async ({ request }) => {
const response = await request.get('/api/health') const response = await request.get('/api/health')
expect(response.status()).toBe(200) expect(response.status()).toBe(200)
await expect(response).toBeOK() await expect(response).toBeOK()
const body = await response.json()
await expect(response.json()).resolves.toMatchObject({ status: 'ok' }) await expect(response.json()).resolves.toMatchObject({ status: 'ok' })
// The deployed build must carry a git SHA stamp (deploy.sh injects it).
expect(typeof body.deployVersion).toBe('string')
expect(body.deployVersion).not.toBe('unversioned')
}) })
test('SMOKE-02: homepage renders its critical shell without console errors', async ({ page }) => { test('SMOKE-02: homepage renders its critical shell without console errors', async ({ page }) => {
+3
View File
@@ -8,6 +8,9 @@ export async function GET() {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime(), uptime: process.uptime(),
version: process.env.npm_package_version || '1.0.0', version: process.env.npm_package_version || '1.0.0',
// Git SHA injected at build time by deploy.sh (Dockerfile ARG) — identifies
// the running build; null in local dev where no deploy stamp exists.
deployVersion: process.env.DEPLOY_VERSION || null,
environment: process.env.NODE_ENV || 'development', environment: process.env.NODE_ENV || 'development',
}; };
+3 -1
View File
@@ -4,7 +4,7 @@
const isStaging = () => process.env.CMS_DEPLOY_ENV === 'staging'; const isStaging = () => process.env.CMS_DEPLOY_ENV === 'staging';
// FILE_LABELS is injected to avoid a circular dependency with the main file. // FILE_LABELS is injected to avoid a circular dependency with the main file.
const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, contentHash) => `<!DOCTYPE html> const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, contentHash, deployVersion) => `<!DOCTYPE html>
<html lang="hu"> <html lang="hu">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -68,6 +68,7 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; } .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; }
.btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; } .btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
.btn-logout:hover { background: #374151; } .btn-logout:hover { background: #374151; }
.version-tag { color: #475569; font-size: 12px; font-family: monospace; }
.btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; } .btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
.btn-save:hover { opacity: .9; transform: translateY(-1px); } .btn-save:hover { opacity: .9; transform: translateY(-1px); }
.btn-save:active { transform: translateY(0); } .btn-save:active { transform: translateY(0); }
@@ -112,6 +113,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
<span class="save-status" id="saveStatus"></span> <span class="save-status" id="saveStatus"></span>
<a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a> <a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a> <a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
<span class="version-tag" title="Futó kód verziója (git SHA)">v${deployVersion}</span>
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button> <button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
</div> </div>
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* Integration test for CMS deploy versioning (MITHOME-63):
* 1. GET /version is public and reports the git SHA of the checked-out commit
* 2. the editor page displays the same version in the bottom bar
* 3. a startup audit entry records the version
*/
const assert = require('assert/strict');
const { execFileSync, spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const PORT = 4129;
const BASE = `http://127.0.0.1:${PORT}`;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cms-version-'));
const auditFile = path.join(tmp, 'audit.jsonl');
const expectedVersion = execFileSync('git', ['-C', ROOT, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim();
const child = spawn('node', ['content-editor.js'], {
cwd: ROOT,
env: {
...process.env,
CONTENT_EDITOR_PORT: String(PORT),
CONTENT_EDITOR_AUDIT_FILE: auditFile,
CMS_USER: 'version-test-user',
CMS_PASS: 'version-test-pass',
CMS_DEPLOY_ENV: 'staging',
},
stdio: 'ignore',
});
async function waitForServer(timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fetch(`${BASE}/version`);
return;
} catch {
await new Promise(r => setTimeout(r, 200));
}
}
throw new Error('server did not start');
}
async function main() {
await waitForServer();
// 1. public /version
const res = await fetch(`${BASE}/version`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.version, expectedVersion);
assert.equal(body.env, 'staging');
// 2. footer shows the same version
const login = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: 'version-test-user', pass: 'version-test-pass' }),
});
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
const page = await (await fetch(`${BASE}/`, { headers: { Cookie: cookie, Accept: 'text/html' } })).text();
assert.ok(page.includes(`v${expectedVersion}`), 'bottom bar must show the deploy version');
// 3. startup audit entry
const audit = fs.readFileSync(auditFile, 'utf8').trim().split('\n').map(l => JSON.parse(l));
const startup = audit.find(e => e.event === 'startup');
assert.ok(startup, 'startup audit entry exists');
assert.equal(startup.version, expectedVersion);
console.log('Content Editor deploy version test: OK');
}
main()
.catch(err => { console.error('❌', err.message); process.exitCode = 1; })
.finally(() => {
child.kill('SIGTERM');
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
});