feat: expose verifiable deployment release

This commit is contained in:
2026-09-15 15:17:24 +03:00
parent a7495a1fe6
commit 4d191a07b1
7 changed files with 164 additions and 8 deletions
+4
View File
@@ -1,3 +1,5 @@
ARG SOURCE_COMMIT=unknown
# Stage 1: Vite SPA build # Stage 1: Vite SPA build
FROM node:22-slim AS build FROM node:22-slim AS build
WORKDIR /app WORKDIR /app
@@ -11,6 +13,8 @@ RUN npm run build
# indirme için gerekli. Sunucu darlığında DT_ENABLED=0 ile kapatılabilir; # indirme için gerekli. Sunucu darlığında DT_ENABLED=0 ile kapatılabilir;
# uygulama ve diğer tüm veri kaynakları Chromium olmadan da çalışır. # uygulama ve diğer tüm veri kaynakları Chromium olmadan da çalışır.
FROM node:22-slim FROM node:22-slim
ARG SOURCE_COMMIT
ENV APP_RELEASE=${SOURCE_COMMIT}
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx ca-certificates curl \ && apt-get install -y --no-install-recommends nginx ca-certificates curl \
+11
View File
@@ -9,6 +9,7 @@ Bu çalışma notu üretim dağıtımında kullanılacak kontrol sırasını tan
- `CORS_ORIGINS`: frontend originlerinin virgülle ayrılmış tam HTTPS adresleri. - `CORS_ORIGINS`: frontend originlerinin virgülle ayrılmış tam HTTPS adresleri.
- Sağlayıcı kullanılacaksa ilgili `GOOGLE_CLIENT_ID`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY` ve `TELEGRAM_BOT_TOKEN` değerleri. - Sağlayıcı kullanılacaksa ilgili `GOOGLE_CLIENT_ID`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY` ve `TELEGRAM_BOT_TOKEN` değerleri.
- `IHALEIST_USER_DATA_DIR` ve `IHALEIST_SHARED_DATA_DIR`: kalıcı volume altında, uygulama dışından erişilemeyen dizinler. - `IHALEIST_USER_DATA_DIR` ve `IHALEIST_SHARED_DATA_DIR`: kalıcı volume altında, uygulama dışından erişilemeyen dizinler.
- Coolify Advanced ayarında **Include Source Commit in Build** açık olmalıdır. Docker imajı `SOURCE_COMMIT` değerini `/api/version` yanıtındaki `release` alanına taşır; bu değer gizli değildir.
## Compose ile staging smoke testi ## Compose ile staging smoke testi
@@ -34,6 +35,16 @@ Coolify uygulaması `ihaleist:main` için sağlık kontrolü `GET http://localho
3. Yeni imaj staging’de başlatılır; `/api/health`, giriş, dosya yükleme/indirme ve beş sabit regresyon ihalesi doğrulanır. 3. Yeni imaj staging’de başlatılır; `/api/health`, giriş, dosya yükleme/indirme ve beş sabit regresyon ihalesi doğrulanır.
4. CORS allowlistesi, auth secret ve sağlayıcı test anahtarları staging yanıtlarında kontrol edilir. 4. CORS allowlistesi, auth secret ve sağlayıcı test anahtarları staging yanıtlarında kontrol edilir.
Hedef commit ve yerel frontend asset hash’i verilerek salt okunur canlı kabul çalıştırılır:
```sh
IHALEIST_EXPECTED_RELEASE="$(git rev-parse HEAD)" \
IHALEIST_EXPECTED_ASSET_SHA256="$(sha256sum dist/assets/index-*.js | awk '{print $1}')" \
npm run qa:live -- https://ihaleist.com
```
macOS üzerinde `sha256sum` yerine `shasum -a 256` kullanılabilir. Probe `/api/version` release değerini, ana sayfadaki gerçek asset’i ve container içinden bildirilen asset hash’ini birlikte karşılaştırır.
## Kademeli yayın ## Kademeli yayın
Önce tek test firmasıyla trafik açılır. Kayıt, belge, analiz ve bildirim metrikleri izlenir. Hata oranı veya veri tutarsızlığı artarsa yeni trafik durdurulur; dış sağlayıcılara gerçek ödeme, e-posta veya resmi teklif gönderimi yapılmaz. Önce tek test firmasıyla trafik açılır. Kayıt, belge, analiz ve bildirim metrikleri izlenir. Hata oranı veya veri tutarsızlığı artarsa yeni trafik durdurulur; dış sağlayıcılara gerçek ödeme, e-posta veya resmi teklif gönderimi yapılmaz.
+3
View File
@@ -65,6 +65,7 @@ import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolv
import { bildirimleriIsle } from './notifier.mjs'; import { bildirimleriIsle } from './notifier.mjs';
import { scanSite } from './site-scan.mjs'; import { scanSite } from './site-scan.mjs';
import { createDetailCache } from './detail-cache.mjs'; import { createDetailCache } from './detail-cache.mjs';
import { releaseInfo } from './release-info.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz. // zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -75,6 +76,7 @@ const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA];
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const ILAN_BASE = 'https://www.ilan.gov.tr'; const ILAN_BASE = 'https://www.ilan.gov.tr';
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean); const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
const RUNTIME_RELEASE = releaseInfo();
function readiness() { function readiness() {
const directories = { const directories = {
@@ -1732,6 +1734,7 @@ const server = http.createServer(async (req, res) => {
} }
if (url.pathname === '/api/health') return json(res, 200, { ok: true }); if (url.pathname === '/api/health') return json(res, 200, { ok: true });
if (url.pathname === '/api/version') return json(res, 200, { ok: true, ...RUNTIME_RELEASE });
if (url.pathname === '/api/ready') { if (url.pathname === '/api/ready') {
const status = readiness(); const status = readiness();
return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status }); return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status });
+50
View File
@@ -0,0 +1,50 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_ASSET_ROOTS = [
'/usr/share/nginx/html',
path.resolve(process.cwd(), 'dist'),
];
function cleanRelease(value) {
const release = String(value || '').trim();
if (!release || release === 'unknown' || release.length > 128) return null;
return release;
}
export function deployedRelease(env = process.env) {
return cleanRelease(env.APP_RELEASE) || cleanRelease(env.SOURCE_COMMIT);
}
export function frontendAssetInfo(roots = DEFAULT_ASSET_ROOTS) {
for (const root of roots) {
try {
const indexPath = path.join(root, 'index.html');
const html = fs.readFileSync(indexPath, 'utf8');
const match = html.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
if (!match) continue;
const publicPath = new URL(match[1], 'https://ihaleist.invalid/').pathname;
const assetPath = path.resolve(root, `.${publicPath}`);
const rootPath = path.resolve(root) + path.sep;
if (!assetPath.startsWith(rootPath)) continue;
const bytes = fs.readFileSync(assetPath);
return {
path: publicPath,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
bytes: bytes.length,
};
} catch {
// Bu kökte derlenmiş arayüz yoksa sıradaki olası köke bakılır.
}
}
return null;
}
export function releaseInfo({ env = process.env, roots = DEFAULT_ASSET_ROOTS } = {}) {
return {
release: deployedRelease(env),
version: '1.0.0',
frontendAsset: frontendAssetInfo(roots),
};
}
+13 -1
View File
@@ -20,7 +20,7 @@ const request=async(path,method='GET',body,auth=token)=>{
before(async()=>{ before(async()=>{
const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r)); const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r));
base='http://127.0.0.1:'+port; base='http://127.0.0.1:'+port;
child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'}); child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',APP_RELEASE:'0123456789abcdef',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'});
for(let i=0;i<100;i++){try{await fetch(base+'/api/auth/google/config');return;}catch{await new Promise(r=>setTimeout(r,50));}} for(let i=0;i<100;i++){try{await fetch(base+'/api/auth/google/config');return;}catch{await new Promise(r=>setTimeout(r,50));}}
throw new Error('QA API did not start'); throw new Error('QA API did not start');
}); });
@@ -55,6 +55,18 @@ test('readiness endpoint checks persistent data directories',async()=>{
assert.equal(response.data.checks.userData.ok,true); assert.equal(response.data.checks.userData.ok,true);
assert.equal(response.data.checks.sharedData.ok,true); assert.equal(response.data.checks.sharedData.ok,true);
}); });
test('version endpoint identifies the running release and frontend asset',async()=>{
const response=await request('/api/version','GET',null,null);
assert.equal(response.status,200);
assert.equal(response.data.ok,true);
assert.equal(response.data.release,'0123456789abcdef');
assert.equal(response.data.version,'1.0.0');
if(response.data.frontendAsset){
assert.match(response.data.frontendAsset.path,/^\/assets\/.+\.js$/);
assert.match(response.data.frontendAsset.sha256,/^[a-f0-9]{64}$/);
assert.ok(response.data.frontendAsset.bytes>0);
}
});
test('HTTP documents and API keys expose empty successful state, not endless load errors',async()=>{ test('HTTP documents and API keys expose empty successful state, not endless load errors',async()=>{
const docs=await request('/api/me/documents');assert.equal(docs.status,200);assert.deepEqual(docs.data.belgeler,[]); const docs=await request('/api/me/documents');assert.equal(docs.status,200);assert.deepEqual(docs.data.belgeler,[]);
const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]); const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]);
+52 -5
View File
@@ -2,6 +2,7 @@
// provider calls are performed. It intentionally fails when a required // provider calls are performed. It intentionally fails when a required
// deployment contract is missing. // deployment contract is missing.
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import crypto from 'node:crypto';
const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, ''); const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, '');
const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json'; const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json';
@@ -13,8 +14,10 @@ const requiredHeaders = [
]; ];
const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])]; const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])];
const checks = {}; const checks = {};
const expectedRelease = String(process.env.IHALEIST_EXPECTED_RELEASE || '').trim();
const expectedAssetSha256 = String(process.env.IHALEIST_EXPECTED_ASSET_SHA256 || '').trim();
async function probe(path, successField = null) { async function probe(path, successField = null, captureText = false) {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
@@ -24,11 +27,14 @@ async function probe(path, successField = null) {
contentType: response.headers.get('content-type'), contentType: response.headers.get('content-type'),
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])), headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
}; };
if (successField) { if (successField || captureText) {
const raw = await response.text(); const raw = await response.text();
if (captureText) result.text = raw;
if (successField) {
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; } try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
result.apiSuccess = result.json?.[successField] === true; result.apiSuccess = result.json?.[successField] === true;
} }
}
return result; return result;
} catch (error) { } catch (error) {
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message }; return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
@@ -37,22 +43,63 @@ async function probe(path, successField = null) {
} }
} }
checks.home = await probe('/'); async function probeFrontendAsset(publicPath) {
if (!publicPath) return { error: 'Ana sayfada Vite JavaScript asset yolu bulunamadı.' };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(new URL(publicPath, base), { signal: controller.signal, redirect: 'manual' });
const bytes = Buffer.from(await response.arrayBuffer());
return {
status: response.status,
contentType: response.headers.get('content-type'),
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
path: new URL(publicPath, base).pathname,
bytes: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
};
} catch (error) {
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
} finally {
clearTimeout(timer);
}
}
checks.home = await probe('/', null, true);
const assetMatch = checks.home.text?.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
delete checks.home.text;
checks.health = await probe('/api/health', 'ok'); checks.health = await probe('/api/health', 'ok');
// /api/health and /api/ready use the same { ok: true } probe contract. The // /api/health and /api/ready use the same { ok: true } probe contract. The
// readiness payload also includes a separate `ready` diagnostic field. // readiness payload also includes a separate `ready` diagnostic field.
checks.ready = await probe('/api/ready', 'ok'); checks.ready = await probe('/api/ready', 'ok');
checks.version = await probe('/api/version', 'ok');
// The EKAP document fallback depends on the bundled browser being available; // The EKAP document fallback depends on the bundled browser being available;
// check its public capability endpoint without opening a document or mutating // check its public capability endpoint without opening a document or mutating
// any account data. // any account data.
checks.documents = await probe('/api/documents/config', 'tarayiciVar'); checks.documents = await probe('/api/documents/config', 'tarayiciVar');
const missingHeaders = requiredHeaders.filter(name => [checks.home, checks.health, checks.ready, checks.documents].some(check => check?.headers?.[name] == null)); checks.frontendAsset = await probeFrontendAsset(assetMatch?.[1]);
const release = checks.version?.json?.release;
const releaseValid = typeof release === 'string' && /^[a-f0-9]{7,64}$/i.test(release);
const releaseMatches = !expectedRelease || release === expectedRelease;
const reportedAsset = checks.version?.json?.frontendAsset;
const assetMatchesRuntime = Boolean(reportedAsset?.sha256)
&& reportedAsset.sha256 === checks.frontendAsset?.sha256
&& reportedAsset.path === checks.frontendAsset?.path;
const assetMatchesExpected = !expectedAssetSha256 || checks.frontendAsset?.sha256 === expectedAssetSha256;
const publicChecks = [checks.home, checks.health, checks.ready, checks.version, checks.documents, checks.frontendAsset];
const missingHeaders = requiredHeaders.filter(name => publicChecks.some(check => check?.headers?.[name] == null));
const report = { const report = {
base, base,
readOnly: true, readOnly: true,
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
expectedRelease: expectedRelease || null,
expectedAssetSha256: expectedAssetSha256 || null,
checks, checks,
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && missingHeaders.length === 0, releaseValid,
releaseMatches,
assetMatchesRuntime,
assetMatchesExpected,
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.version?.status === 200 && checks.version.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && checks.frontendAsset?.status === 200 && releaseValid && releaseMatches && assetMatchesRuntime && assetMatchesExpected && missingHeaders.length === 0,
missingHeaders, missingHeaders,
}; };
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { deployedRelease, frontendAssetInfo } from '../server/release-info.mjs';
test('release metadata rejects missing build markers', () => {
assert.equal(deployedRelease({}), null);
assert.equal(deployedRelease({ APP_RELEASE: 'unknown' }), null);
assert.equal(deployedRelease({ SOURCE_COMMIT: 'abc1234' }), 'abc1234');
assert.equal(deployedRelease({ APP_RELEASE: 'release-a', SOURCE_COMMIT: 'release-b' }), 'release-a');
});
test('frontend asset metadata hashes the script referenced by index.html', () => {
const root = mkdtempSync(join(tmpdir(), 'ihaleist-release-'));
try {
mkdirSync(join(root, 'assets'));
writeFileSync(join(root, 'index.html'), '<script type="module" src="/assets/index-test.js"></script>');
writeFileSync(join(root, 'assets/index-test.js'), 'console.log("release");\n');
assert.deepEqual(frontendAssetInfo([root]), {
path: '/assets/index-test.js',
sha256: 'ed81365d3ce25c2e499a43c7d91c72cbc05c6475a19423f18fc3917c96857e80',
bytes: 24,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});