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
+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,
};