feat: add manual production deployment pipeline
Deploy EG-BIM QA Gateway / deploy (push) Failing after 7s

This commit is contained in:
root committed 2026-10-28 14:14:02 +09:00
1 parent 1808f14cc6
commit 0755155368
13 files changed
+1391 -5

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
import { spawn } from 'node:child_process';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { productionSettings } from './production-settings.mjs';
function execute(args) {
return new Promise((resolve, reject) => {
const child = spawn('npx', ['--no-install', 'wrangler', ...args], { stdio: 'inherit' });
child.once('error', () => reject(new Error('Production Worker command could not start')));
child.once('exit', (code) => code === 0 ? resolve() : reject(new Error('Production Worker deployment failed')));
});
}
let secretDirectory;
try {
const options = process.argv.slice(2);
if (options.some((option) => !['--dry-run', '--validate-only'].includes(option))) throw new Error('Unknown production deployment option');
const settings = productionSettings(process.env);
console.log(JSON.stringify({ worker: settings.worker, bucket: settings.bucket, origin: settings.origin, assets: settings.vars.QA_ASSET_PREFIX }));
if (!options.includes('--validate-only')) {
const args = ['deploy', '--config', settings.config, '--name', settings.worker];
for (const [name, value] of Object.entries(settings.vars)) args.push('--var', name + ':' + value);
if (options.includes('--dry-run')) {
args.push('--dry-run');
} else {
// Deploy code and secrets together; keep secret values out of arguments and logs.
secretDirectory = await mkdtemp(join(tmpdir(), 'qa-production-secrets-'));
const secretPath = join(secretDirectory, 'secrets.json');
await writeFile(secretPath, JSON.stringify(settings.secrets), { mode: 0o600 });
args.push('--secrets-file', secretPath);
}
await execute(args);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
if (secretDirectory) await rm(secretDirectory, { recursive: true, force: true });
}
+55
View File
@@ -0,0 +1,55 @@
import { orgContextConfig } from '../src/org-context.js';
import { deploymentOrgSettings } from './org-context-settings.mjs';
export const productionTarget = Object.freeze({
worker: 'baron-qa-gateway-prod',
config: 'wrangler.production.toml',
bucket: 'baron-qa-write',
origin: 'https://qa.baroncs.co.kr'
});
function required(env, name) {
const value = String(env[name] || '').trim();
if (!value) throw new Error(name + ' is required for production');
return value;
}
function httpsBase(value, name) {
let url;
try { url = new URL(value); } catch { throw new Error(name + ' must be an HTTPS origin'); }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new Error(name + ' must be an HTTPS origin');
}
return url.origin;
}
export function productionSettings(env) {
// These names are mapped exclusively from PROD_* settings in the production workflow.
required(env, 'CLOUDFLARE_API_TOKEN');
const secrets = {
SESSION_SECRET: required(env, 'SESSION_SECRET'),
ABC_API_KEY: required(env, 'ABC_API_KEY'),
ORG_CONTEXT_KEY_ID: required(env, 'ORG_CONTEXT_KEY_ID'),
ORG_CONTEXT_KEY_SECRET: required(env, 'ORG_CONTEXT_KEY_SECRET')
};
if (secrets.SESSION_SECRET.length < 32) throw new Error('SESSION_SECRET must contain at least 32 characters');
if (required(env, 'ORG_CONTEXT_ENABLED') !== 'true') throw new Error('ORG_CONTEXT_ENABLED must be true for production');
const organization = deploymentOrgSettings(env);
orgContextConfig({ ...organization.vars, ...secrets });
const vars = {
...organization.vars,
AUTH_CLIENT_ID: required(env, 'AUTH_CLIENT_ID'),
ABC_API_BASE_URL: httpsBase(required(env, 'ABC_API_BASE_URL'), 'ABC_API_BASE_URL'),
ABC_PROJECT_ID: required(env, 'ABC_PROJECT_ID'),
ABC_CHANNEL_ID: required(env, 'ABC_CHANNEL_ID')
};
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
for (const name of ['ABC_PROJECT_ID', 'ABC_CHANNEL_ID']) {
if (!uuid.test(vars[name])) throw new Error(name + ' must be a UUID');
}
const revision = required(env, 'DEPLOY_COMMIT_SHA');
if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('DEPLOY_COMMIT_SHA must be a full lowercase Git commit SHA');
vars.QA_ASSET_PREFIX = 'releases/' + revision;
return { ...productionTarget, vars, secrets };
}
+11 -2
View File
@@ -2,12 +2,21 @@
set -euo pipefail
bucket="${1:-baron-qa-pages}"
config="${2:-wrangler.toml}"
prefix="${3:-}"
if [ "$bucket" = "baron-qa-write" ]; then
if [ "$config" != "wrangler.production.toml" ] || [[ ! "$prefix" =~ ^releases/[0-9a-f]{40}$ ]]; then
echo "Production uploads require the production config and a full commit release prefix" >&2
exit 1
fi
fi
upload() {
local file="$1"
local key="$2"
local key="${prefix:+$prefix/}$2"
local content_type="$3"
npx wrangler r2 object put "$bucket/$key" --file "$file" --content-type "$content_type" --remote
npx --no-install wrangler r2 object put "$bucket/$key" --config "$config" --file "$file" --content-type "$content_type" --remote
}
upload egbim/index.html egbim/index.html text/html
+39
View File
@@ -0,0 +1,39 @@
import { productionTarget } from './production-settings.mjs';
import { pathToFileURL } from 'node:url';
import { setTimeout as wait } from 'node:timers/promises';
export async function verifyProduction(fetchImpl = fetch, revision = process.env.DEPLOY_COMMIT_SHA) {
const origin = productionTarget.origin;
const request = (path) => fetchImpl(origin + path, { redirect: 'manual', signal: AbortSignal.timeout(15000), headers: { 'cache-control': 'no-cache' } });
const session = await request('/auth/session');
if (!session.ok) throw new Error('Production session endpoint failed');
const body = await session.json();
if (body.authenticated !== false || body.user !== null) throw new Error('Production session endpoint returned an unexpected anonymous session');
const asset = await request('/assets/styles.css');
if (!asset.ok || !String(asset.headers.get('content-type')).includes('text/css') || !(await asset.text()).trim()) {
throw new Error('Production stylesheet is unavailable');
}
if (revision && asset.headers.get('x-qa-release') !== 'releases/' + revision) {
throw new Error('Production stylesheet does not match the deployed commit');
}
const protectedPage = await request('/egbim/write.html');
const login = new URL(protectedPage.headers.get('location') || '/', origin);
if (protectedPage.status !== 302 || login.origin !== origin || login.pathname !== '/auth/login') {
throw new Error('Production write page must redirect anonymous visitors to login');
}
console.log('Production HTTP checks passed: session, stylesheet, protected write page.');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
for (let attempt = 1; attempt <= 5; attempt++) {
try { await verifyProduction(); break; } catch (error) {
if (attempt === 5) {
console.error(error.message);
process.exitCode = 1;
} else {
console.log('Waiting for production deployment propagation (' + attempt + '/5).');
await wait(5000);
}
}
}
}