Add containerized staging deployment
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 52s

This commit is contained in:
JB
2026-07-26 10:31:24 -04:00
parent 32e10c710b
commit 548d19d9ea
25 changed files with 1692 additions and 21 deletions
+19 -1
View File
@@ -99,6 +99,22 @@ if (!notificationColumns.some((column) => column.name === 'actions_json')) {
await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT');
}
const legacyProtocolEffects = await (await database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'"
)).get();
if (legacyProtocolEffects) {
const legacyProtocolEffectCount = await (await database.prepare(
'SELECT COUNT(*) AS count FROM os_protocol_attribute_effects'
)).get();
if (Number(legacyProtocolEffectCount?.count ?? 0) > 0) {
throw new Error(
'Legacy OS Protocol attribute effects still contain rows. Export and reconcile them before removing the retired table.'
);
}
console.log('Removing the empty legacy OS Protocol attribute-effects table.');
await database.exec('DROP TABLE os_protocol_attribute_effects');
}
const proposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get();
@@ -355,7 +371,9 @@ await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted) {
if (!databaseAlreadyExisted && process.env.SKIP_DEFAULT_USERS !== 'true') {
console.log('New database detected; creating default users.');
await initializeDefaultUsers(database);
} else if (!databaseAlreadyExisted) {
console.log('New database detected; default-user seeding is disabled for this environment.');
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import process from 'node:process';
const files = ['.env.development', '.env.production'];
const files = ['.env.development', '.env.staging', '.env.production'];
const action = process.argv[2];
const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git';
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
+48
View File
@@ -0,0 +1,48 @@
import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
const outputPath = resolve(process.argv[2] || `./data/backups/isac-data-${timestamp}.json`);
const database = await connect(databasePath);
const tables = [];
let totalRows = 0;
for (const name of ISAC_DATA_TABLES) {
const exists = await (await database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?"
)).get(name);
if (!exists) throw new Error(`ISAC export requires missing table "${name}". Run the schema migration first.`);
const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all();
tables.push({ name, rowCount: rows.length, rows });
totalRows += rows.length;
}
const icons = [];
const iconTable = tables.find((table) => table.name === 'game_data_icons');
for (const row of iconTable?.rows ?? []) {
if (typeof row.storage_key !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) {
throw new Error(`Invalid game-data icon storage key "${String(row.storage_key)}".`);
}
const content = await readFile(join(iconDirectory, row.storage_key));
icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') });
}
const snapshot = {
format: 'shd-build-isac-data',
version: 1,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
tables,
icons
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded icons.`);
console.log(`ISAC snapshot: ${outputPath}`);
+79
View File
@@ -0,0 +1,79 @@
import { connect } from '@tursodatabase/database';
import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import process from 'node:process';
import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs';
const snapshotArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
if (!snapshotArgument || !process.argv.includes('--confirm-replace-isac-data')) {
console.error('Usage: node scripts/isac-data-import.mjs <snapshot.json> --confirm-replace-isac-data');
process.exit(2);
}
const snapshotPath = resolve(snapshotArgument);
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
if (snapshot?.format !== 'shd-build-isac-data' || snapshot?.version !== 1 || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons)) {
throw new Error('The selected file is not a supported SHD.build ISAC data snapshot.');
}
const receivedNames = snapshot.tables.map((table) => table.name);
if (receivedNames.length !== ISAC_DATA_TABLES.length || ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name)) {
throw new Error('The ISAC snapshot table manifest is incomplete or out of order.');
}
for (const icon of snapshot.icons) {
if (typeof icon.storageKey !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey)
|| typeof icon.contentBase64 !== 'string') {
throw new Error('The ISAC snapshot contains an invalid uploaded icon.');
}
}
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db');
const backupPath = resolve(`./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`);
const isacBackupPath = resolve(`./data/backups/isac-data-before-import-${timestamp}.json`);
for (const [script, output] of [
['scripts/db-backup.mjs', backupPath],
['scripts/isac-data-export.mjs', isacBackupPath]
]) {
const result = spawnSync(process.execPath, [script, output], { cwd: process.cwd(), stdio: 'inherit', env: process.env });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Safety backup failed in ${script}; no ISAC data was changed.`);
}
const database = await connect(databasePath);
let importedRows = 0;
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
for (const name of [...ISAC_DATA_TABLES].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(name)}`);
}
for (const table of snapshot.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`
);
await insert.run(...columns.map((column) => row[column]));
importedRows++;
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
await mkdir(iconDirectory, { recursive: true });
for (const icon of snapshot.icons) {
await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 });
}
console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded icons from ${snapshotPath}.`);
console.log(`Full pre-import backup: ${backupPath}`);
console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);
+54
View File
@@ -0,0 +1,54 @@
export const ISAC_DATA_TABLES = [
'stat_attributes',
'talents',
'talent_tiers',
'talent_triggers',
'talent_tier_triggers',
'talent_tier_effects',
'talent_description_templates',
'talent_weapon_scopes',
'weapon_talent_slots',
'brand_sets',
'brand_set_effects',
'gear',
'gear_brand_sets',
'gear_talents',
'gear_tactical_augments',
'gear_tactical_augment_costs',
'weapons',
'core_attribute_tier_ranges',
'weapon_tier_ranges',
'weapon_talent_options',
'weapon_augmentation_effects',
'weapon_base_attribute_bonuses',
'os_protocols',
'os_protocol_primary_effects',
'os_protocol_secondary_profiles',
'os_protocol_secondary_effects',
'os_protocol_talents',
'os_protocol_talent_triggers',
'os_protocol_talent_effects',
'skill_chip_sets',
'skill_chip_two_piece_bonuses',
'skill_chip_primary_options',
'skill_chip_secondary_options',
'skill_chip_talents',
'skill_chip_talent_triggers',
'skill_chip_talent_effects',
'weapon_mods',
'weapon_mod_compatible_categories',
'weapon_mod_slots',
'weapon_mod_quality_values',
'weapon_mod_secondary_options',
'weapon_mod_secondary_quality_values',
'weapon_mod_exotic_attributes',
'weapon_mod_effects',
'weapon_mod_effect_quality_values',
'gear_mods',
'gear_mod_quality_values',
'gear_mod_secondary_options',
'gear_mod_secondary_quality_values',
'game_data_icons'
];
export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
+54
View File
@@ -0,0 +1,54 @@
import { spawnSync } from 'node:child_process';
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
const outputArgument = process.argv[2];
const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z');
const outputPath = resolve(outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`);
const temporaryBackupPath = `${outputPath}.database.json`;
const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env
});
if (databaseBackup.error) throw databaseBackup.error;
if (databaseBackup.status !== 0) throw new Error('Database backup failed; no staging bootstrap bundle was created.');
async function collectFiles(directory, pattern) {
try {
const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort();
return await Promise.all(names.map(async (name) => ({
name,
contentBase64: (await readFile(join(directory, name))).toString('base64')
})));
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
}
try {
const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8'));
const avatars = await collectFiles(
resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'),
/^[0-9a-f-]{36}\.webp$/i
);
const gameIcons = await collectFiles(
resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'),
/^[a-z_]+-[0-9a-f-]{36}\.webp$/i
);
const bundle = {
format: 'shd-build-stage-bootstrap',
version: 1,
createdAt: new Date().toISOString(),
database,
files: { avatars, gameIcons }
};
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
console.log(`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`);
console.log(`Bootstrap bundle: ${outputPath}`);
} finally {
await rm(temporaryBackupPath, { force: true });
}
+109
View File
@@ -0,0 +1,109 @@
import { connect } from '@tursodatabase/database';
import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import process from 'node:process';
const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--'));
if (!bundleArgument || !process.argv.includes('--confirm-empty-staging-replace')
|| process.env.ALLOW_STAGING_BOOTSTRAP !== 'true') {
console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:');
console.error('node scripts/stage-bootstrap-import.mjs <bundle.json> --confirm-empty-staging-replace');
process.exit(2);
}
const bundlePath = resolve(bundleArgument);
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
if (bundle?.format !== 'shd-build-stage-bootstrap' || bundle?.version !== 1
|| bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1
|| !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars)
|| !Array.isArray(bundle.files?.gameIcons)) {
throw new Error('The selected file is not a supported SHD.build staging bootstrap bundle.');
}
for (const file of bundle.files.avatars) {
if (typeof file.name !== 'string' || !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') {
throw new Error('The bootstrap bundle contains an invalid avatar file.');
}
}
for (const file of bundle.files.gameIcons) {
if (typeof file.name !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') {
throw new Error('The bootstrap bundle contains an invalid game-data icon file.');
}
}
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-staging.db');
const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env
});
if (safetyBackup.error) throw safetyBackup.error;
if (safetyBackup.status !== 0) throw new Error('Staging safety backup failed; the target was not changed.');
const database = await connect(databasePath);
for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) {
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get();
if (Number(row?.count ?? 0) > 0) {
throw new Error(`Staging bootstrap refused: target table "${tableName}" is not empty.`);
}
}
const targetTables = await (await database.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY rowid
`)).all();
const targetNames = new Set(targetTables.map((row) => String(row.name)));
const sourceNames = new Set(bundle.database.tables.map((table) => String(table.name)));
for (const row of targetTables) {
if (!sourceNames.has(String(row.name))) {
throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`);
}
}
const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) {
console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`);
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0;
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`
);
await insert.run(...columns.map((column) => row[column]));
restoredRows++;
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars');
const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons');
await mkdir(avatarDirectory, { recursive: true });
await mkdir(gameIconDirectory, { recursive: true });
for (const file of bundle.files.avatars) {
await writeFile(join(avatarDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
}
for (const file of bundle.files.gameIcons) {
await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 });
}
console.log(`Bootstrapped staging with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`);
console.log(`Source bundle: ${bundlePath}`);
+46
View File
@@ -0,0 +1,46 @@
import { spawnSync } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import process from 'node:process';
const stagingFile = resolve('.env.staging');
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
const sharedKeys = [
'DISCORD_CLIENT_ID',
'DISCORD_CLIENT_SECRET',
'SMTP_HOST',
'SMTP_PORT',
'SMTP_SECURE',
'SMTP_USER',
'SMTP_PASSWORD',
'SMTP_FROM'
];
for (const key of sharedKeys) {
if (!process.env[key]) throw new Error(`Source environment is missing ${key}.`);
}
function dotenvx(action) {
const result = spawnSync(process.execPath, [dotenvxCli, action, '-f', stagingFile], {
cwd: process.cwd(),
stdio: 'inherit'
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`);
}
dotenvx('decrypt');
try {
let contents = await readFile(stagingFile, 'utf8');
for (const key of sharedKeys) {
const replacement = `${key}=${JSON.stringify(process.env[key])}`;
const pattern = new RegExp(`^${key}=.*$`, 'm');
if (!pattern.test(contents)) throw new Error(`Staging environment is missing ${key}.`);
contents = contents.replace(pattern, replacement);
}
await writeFile(stagingFile, contents, 'utf8');
} finally {
dotenvx('encrypt');
}
console.log('Copied shared Discord and SMTP credentials into encrypted staging configuration.');