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
FROM node:22-slim AS build
WORKDIR /app
@@ -11,6 +13,8 @@ RUN npm run build
# 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.
FROM node:22-slim
ARG SOURCE_COMMIT
ENV APP_RELEASE=${SOURCE_COMMIT}
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& 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.
- 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.
- 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
@@ -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.
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
Ö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 { scanSite } from './site-scan.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;
// 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 ILAN_BASE = 'https://www.ilan.gov.tr';
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
const RUNTIME_RELEASE = releaseInfo();
function readiness() {
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/version') return json(res, 200, { ok: true, ...RUNTIME_RELEASE });
if (url.pathname === '/api/ready') {
const status = readiness();
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()=>{
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;
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));}}
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.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()=>{
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,[]);
+54 -7
View File
@@ -2,6 +2,7 @@
// provider calls are performed. It intentionally fails when a required
// deployment contract is missing.
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 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 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 timer = setTimeout(() => controller.abort(), timeoutMs);
try {
@@ -24,10 +27,13 @@ async function probe(path, successField = null) {
contentType: response.headers.get('content-type'),
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
};
if (successField) {
if (successField || captureText) {
const raw = await response.text();
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
result.apiSuccess = result.json?.[successField] === true;
if (captureText) result.text = raw;
if (successField) {
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
result.apiSuccess = result.json?.[successField] === true;
}
}
return result;
} catch (error) {
@@ -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');
// /api/health and /api/ready use the same { ok: true } probe contract. The
// readiness payload also includes a separate `ready` diagnostic field.
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;
// check its public capability endpoint without opening a document or mutating
// any account data.
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 = {
base,
readOnly: true,
checkedAt: new Date().toISOString(),
expectedRelease: expectedRelease || null,
expectedAssetSha256: expectedAssetSha256 || null,
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,
};
+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 });
}
});