Skip to content

Commit aecd15b

Browse files
committed
Add env separation audit script and deploy workflow defaults
1 parent 9ff167e commit aecd15b

2 files changed

Lines changed: 154 additions & 1 deletion

File tree

.github/workflows/bot-deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ on:
2828
test_deployment:
2929
description: Deploy Render test service name
3030
required: true
31-
default: true
31+
default: false
3232
type: boolean
3333
with_render_smoke:
3434
description: Run Render full-stack smoke after deploy

scripts/audit_env_separation.js

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#!/usr/bin/env node
2+
const fs = require('fs');
3+
const path = require('path');
4+
const crypto = require('crypto');
5+
const { execSync } = require('child_process');
6+
const yaml = require('js-yaml');
7+
8+
function readJsonSafe(filePath) {
9+
try {
10+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
11+
} catch (_) {
12+
return null;
13+
}
14+
}
15+
16+
function readYamlSafe(filePath) {
17+
try {
18+
return yaml.load(fs.readFileSync(filePath, 'utf8'));
19+
} catch (_) {
20+
return null;
21+
}
22+
}
23+
24+
function run(cmd) {
25+
try {
26+
return String(execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }) || '').trim();
27+
} catch (_) {
28+
return '';
29+
}
30+
}
31+
32+
function parseSecretList(raw) {
33+
if (!raw) return [];
34+
return raw.split(/\r?\n/).map((line) => String(line || '').trim()).filter(Boolean).map((line) => line.split(/\s+/)[0]);
35+
}
36+
37+
function pickExistingFile(repoRoot, names) {
38+
for (const name of names) {
39+
const p = path.join(repoRoot, name);
40+
if (fs.existsSync(p)) return p;
41+
}
42+
return '';
43+
}
44+
45+
function maskTokenFingerprint(token) {
46+
const t = String(token || '').trim();
47+
if (!t) return '';
48+
return crypto.createHash('sha256').update(t).digest('hex').slice(0, 10);
49+
}
50+
51+
async function getWebhookInfo(token) {
52+
const t = String(token || '').trim();
53+
if (!t) return null;
54+
try {
55+
const res = await fetch(`https://api.telegram.org/bot${t}/getWebhookInfo`);
56+
const json = await res.json();
57+
if (!res.ok || !json || !json.ok) return null;
58+
return json.result || null;
59+
} catch (_) {
60+
return null;
61+
}
62+
}
63+
64+
function compare(label, a, b) {
65+
const av = String(a || '').trim();
66+
const bv = String(b || '').trim();
67+
if (!av || !bv) return `${label}: unknown (missing value)`;
68+
return `${label}: ${av === bv ? 'COLLISION' : 'separate'}`;
69+
}
70+
71+
async function main() {
72+
const repoRoot = path.resolve(__dirname, '..');
73+
const workflowPath = path.join(repoRoot, '.github/workflows/bot-deploy.yml');
74+
const workflow = readYamlSafe(workflowPath) || {};
75+
const wd = (workflow.on && workflow.on.workflow_dispatch && workflow.on.workflow_dispatch.inputs) || {};
76+
const testDeploymentDefault = wd.test_deployment && wd.test_deployment.default;
77+
78+
const stagingSecrets = parseSecretList(run('gh secret list --env staging --repo ApartsinProjects/Web2Comics'));
79+
const productionSecrets = parseSecretList(run('gh secret list --env production --repo ApartsinProjects/Web2Comics'));
80+
81+
const stMetaPath = pickExistingFile(repoRoot, [
82+
'telegram/out/deploy-render-metadata.staging.json',
83+
'telegram/out/deploy-render-metadata.stage.json',
84+
'telegram/out/deploy-render-metadata-stage.json'
85+
]);
86+
const prMetaPath = pickExistingFile(repoRoot, [
87+
'telegram/out/deploy-render-metadata.production.json',
88+
'telegram/out/deploy-render-metadata.json'
89+
]);
90+
const stMeta = stMetaPath ? (readJsonSafe(stMetaPath) || {}) : {};
91+
const prMeta = prMetaPath ? (readJsonSafe(prMetaPath) || {}) : {};
92+
93+
const stagingToken = String(process.env.STAGING_TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN_STAGING || '').trim();
94+
const productionToken = String(process.env.PRODUCTION_TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN_PRODUCTION || process.env.TELEGRAM_BOT_TOKEN || '').trim();
95+
const stWebhook = await getWebhookInfo(stagingToken);
96+
const prWebhook = await getWebhookInfo(productionToken);
97+
98+
const stNotify = String(process.env.STAGING_NOTIFY_CHAT_ID || stMeta.notifyChatId || '').trim();
99+
const stTest = String(process.env.STAGING_TEST_CHAT_ID || stMeta.telegramTestChatId || '').trim();
100+
const prNotify = String(process.env.PRODUCTION_NOTIFY_CHAT_ID || prMeta.notifyChatId || '').trim();
101+
const prTest = String(process.env.PRODUCTION_TEST_CHAT_ID || prMeta.telegramTestChatId || '').trim();
102+
103+
console.log('=== Web2Comics Env Separation Audit ===');
104+
console.log(`Workflow file: ${workflowPath}`);
105+
console.log(`Workflow input default test_deployment: ${String(testDeploymentDefault)}`);
106+
console.log(`Expected canonical staging service: web2comics-telegram-render-bot-stage`);
107+
console.log(`Expected canonical production service: web2comics-telegram-render-bot`);
108+
console.log('');
109+
110+
console.log('Secrets presence:');
111+
console.log(`- staging has RENDER_SERVICE_NAME: ${stagingSecrets.includes('RENDER_SERVICE_NAME')}`);
112+
console.log(`- production has RENDER_SERVICE_NAME: ${productionSecrets.includes('RENDER_SERVICE_NAME')}`);
113+
console.log(`- staging has TELEGRAM_BOT_TOKEN: ${stagingSecrets.includes('TELEGRAM_BOT_TOKEN')}`);
114+
console.log(`- production has TELEGRAM_BOT_TOKEN: ${productionSecrets.includes('TELEGRAM_BOT_TOKEN')}`);
115+
console.log('');
116+
117+
console.log('Metadata snapshot:');
118+
console.log(`- staging metadata file: ${stMetaPath || '(none)'}`);
119+
console.log(` serviceName=${String(stMeta.serviceName || '') || '-'}`);
120+
console.log(` publicUrl=${String(stMeta.publicUrl || '') || '-'}`);
121+
console.log(` webhookUrl=${String(stMeta.webhookUrl || '') || '-'}`);
122+
console.log(`- production metadata file: ${prMetaPath || '(none)'}`);
123+
console.log(` serviceName=${String(prMeta.serviceName || '') || '-'}`);
124+
console.log(` publicUrl=${String(prMeta.publicUrl || '') || '-'}`);
125+
console.log(` webhookUrl=${String(prMeta.webhookUrl || '') || '-'}`);
126+
console.log('');
127+
128+
console.log('Live Telegram webhook:');
129+
console.log(`- staging token fingerprint: ${maskTokenFingerprint(stagingToken) || '(missing token in env)'}`);
130+
console.log(` url=${String(stWebhook && stWebhook.url || '') || '(unavailable)'}`);
131+
console.log(`- production token fingerprint: ${maskTokenFingerprint(productionToken) || '(missing token in env)'}`);
132+
console.log(` url=${String(prWebhook && prWebhook.url || '') || '(unavailable)'}`);
133+
console.log('');
134+
135+
console.log('Separation checks:');
136+
console.log(`- ${compare('serviceName', stMeta.serviceName, prMeta.serviceName)}`);
137+
console.log(`- ${compare('publicUrl', stMeta.publicUrl || (stWebhook && stWebhook.url), prMeta.publicUrl || (prWebhook && prWebhook.url))}`);
138+
console.log(`- ${compare('notifyChatId', stNotify, prNotify)}`);
139+
console.log(`- ${compare('testChatId', stTest, prTest)}`);
140+
const tfA = maskTokenFingerprint(stagingToken);
141+
const tfB = maskTokenFingerprint(productionToken);
142+
if (tfA && tfB) {
143+
console.log(`- token fingerprint: ${tfA === tfB ? 'COLLISION' : 'separate'}`);
144+
} else {
145+
console.log('- token fingerprint: unknown (missing staging/prod token in local env)');
146+
}
147+
}
148+
149+
main().catch((error) => {
150+
console.error(`[audit_env_separation] failed: ${String(error && error.message ? error.message : error)}`);
151+
process.exit(1);
152+
});
153+

0 commit comments

Comments
 (0)