mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
285 lines
13 KiB
JavaScript
285 lines
13 KiB
JavaScript
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execFileSync, spawnSync } = require('child_process');
|
|
|
|
const sourceBuilder = path.join(__dirname, '..', 'scripts', 'deploy', 'build-release-artifact.sh');
|
|
|
|
function run(command, args, options = {}) {
|
|
return execFileSync(command, args, {
|
|
cwd: options.cwd,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, TZ: 'UTC' },
|
|
stdio: options.stdio || ['ignore', 'pipe', 'pipe']
|
|
});
|
|
}
|
|
|
|
function write(root, relative, contents, mode = 0o644) {
|
|
const target = path.join(root, relative);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, contents, { mode });
|
|
}
|
|
|
|
function fixture() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wowngeon-release-test-'));
|
|
write(root, 'scripts/deploy/build-release-artifact.sh', fs.readFileSync(sourceBuilder), 0o755);
|
|
write(root, 'src/index.js', "'use strict';\n");
|
|
write(root, 'src/package.json', JSON.stringify({
|
|
name: 'fixture',
|
|
version: '1.0.0',
|
|
scripts: {
|
|
start: 'node index.js',
|
|
preflight: 'node scripts/preflight.js',
|
|
'db:migrate:clone': 'node scripts/migrate-disposable-clone.js',
|
|
test: 'jest',
|
|
'capture:pvp': 'node scripts/pvp-capture.js',
|
|
'canary:stagenet': 'node scripts/stagenet-financial-canary.js',
|
|
'db:create': 'node scripts/setup_db.js'
|
|
}
|
|
}) + '\n');
|
|
write(root, 'src/package-lock.json', '{"name":"fixture","lockfileVersion":3}\n');
|
|
write(root, 'src/scripts/preflight.js', "'use strict';\n");
|
|
write(root, 'src/scripts/migrate-disposable-clone.js', "'use strict';\n");
|
|
write(root, 'src/scripts/pvp-capture.js', "throw new Error('source-only ad harness');\n");
|
|
write(root, 'src/scripts/stagenet-financial-canary.js', "throw new Error('source-only transfer canary');\n");
|
|
write(root, 'src/scripts/setup_db.js', "throw new Error('source-only database helper');\n");
|
|
write(root, 'src/scripts/smoke.js', "throw new Error('source-only smoke helper');\n");
|
|
write(root, 'src/jest.config.js', "module.exports = {};\n");
|
|
write(root, 'src/sim/simulate.js', "throw new Error('source-only simulator');\n");
|
|
write(root, 'src/.env.example', 'DB_PASSWORD=CHANGE_ME\n');
|
|
write(root, 'src/.env.production.example', 'ADMIN_API_KEY=CHANGE_ME\n');
|
|
write(root, 'html/index.html', '<!doctype html>\n');
|
|
write(root, 'LICENSE', 'fixture license\n');
|
|
write(root, '.env.example', 'DB_PASSWORD=CHANGE_ME\n');
|
|
write(root, 'README.md', 'source documentation only\n');
|
|
write(root, 'docs/internal.md', 'operator documentation only\n');
|
|
write(root, 'test/runtime-exclusion.test.js', "throw new Error('must not ship');\n");
|
|
write(root, 'scripts/deploy/example.service', '[Service]\nExecStart=/bin/false\n');
|
|
write(root, 'scripts/deploy/migrate-monero-stagenet-wallet.sh', '#!/bin/sh\nexit 1\n', 0o755);
|
|
write(root, '.gitignore', 'out*/\n');
|
|
run('git', ['init', '-q'], { cwd: root });
|
|
run('git', ['config', 'user.name', 'Release Test'], { cwd: root });
|
|
run('git', ['config', 'user.email', 'release-test@example.invalid'], { cwd: root });
|
|
run('git', ['add', '.'], { cwd: root });
|
|
run('git', ['commit', '-qm', 'fixture'], { cwd: root });
|
|
return root;
|
|
}
|
|
|
|
function build(root, output, releaseId = 'candidate-1') {
|
|
return run(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
|
|
'--release-id', releaseId,
|
|
'--output-dir', output
|
|
], { cwd: root });
|
|
}
|
|
|
|
function buildAsync(root, output, releaseId = 'candidate-1') {
|
|
return new Promise(resolve => {
|
|
const child = require('child_process').spawn(
|
|
path.join(root, 'scripts/deploy/build-release-artifact.sh'),
|
|
['--release-id', releaseId, '--output-dir', output],
|
|
{
|
|
cwd: root,
|
|
env: { ...process.env, TZ: 'UTC' },
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
}
|
|
);
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
child.on('close', status => resolve({ status, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
describe('release artifact builder', () => {
|
|
const roots = [];
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('builds byte-identical, runtime-only artifacts with provenance markers', () => {
|
|
const root = fixture();
|
|
roots.push(root);
|
|
const first = path.join(root, 'out-a');
|
|
const second = path.join(root, 'out-b');
|
|
|
|
const output = build(root, first);
|
|
build(root, second);
|
|
|
|
const artifactA = path.join(first, 'candidate-1.tar.gz');
|
|
const artifactB = path.join(second, 'candidate-1.tar.gz');
|
|
expect(fs.readFileSync(artifactA).equals(fs.readFileSync(artifactB))).toBe(true);
|
|
expect(output).toMatch(/^release_id=candidate-1$/m);
|
|
expect(output).toMatch(/^commit=[0-9a-f]{40}$/m);
|
|
expect(output).toMatch(/^source_sha256=[0-9a-f]{64}$/m);
|
|
expect(output).toMatch(/^artifact_sha256=[0-9a-f]{64}$/m);
|
|
|
|
const members = run('tar', ['-tzf', artifactA]).trim().split('\n');
|
|
expect(members).toContain('candidate-1/.release-source-sha256');
|
|
expect(members).toContain('candidate-1/.release-commit');
|
|
expect(members).toContain('candidate-1/.release-package-lock-sha256');
|
|
expect(members).toContain('candidate-1/.release-files.sha256');
|
|
expect(members).toContain('candidate-1/LICENSE');
|
|
expect(members).toContain('candidate-1/src/index.js');
|
|
expect(members).toContain('candidate-1/src/scripts/preflight.js');
|
|
expect(members).toContain('candidate-1/src/scripts/migrate-disposable-clone.js');
|
|
expect(members.some(member => member.includes('/.git/'))).toBe(false);
|
|
expect(members.some(member => member.includes('/node_modules/'))).toBe(false);
|
|
expect(members.some(member => /\/(?:\.env)$/.test(member))).toBe(false);
|
|
expect(members.some(member => member.startsWith('candidate-1/docs/'))).toBe(false);
|
|
expect(members.some(member => member.startsWith('candidate-1/test/'))).toBe(false);
|
|
expect(members.some(member => member.startsWith('candidate-1/scripts/'))).toBe(false);
|
|
expect(members.some(member => member.endsWith('.service'))).toBe(false);
|
|
expect(members).not.toContain('candidate-1/scripts/deploy/migrate-monero-stagenet-wallet.sh');
|
|
expect(members.some(member => /^candidate-1\/src\/\.env(?:\.|$)/.test(member))).toBe(false);
|
|
expect(members.some(member => /\/\.env(?:\..*)?\.example$/.test(member))).toBe(false);
|
|
expect(members).not.toContain('candidate-1/src/scripts/pvp-capture.js');
|
|
expect(members).not.toContain('candidate-1/src/scripts/stagenet-financial-canary.js');
|
|
expect(members).not.toContain('candidate-1/src/scripts/setup_db.js');
|
|
expect(members).not.toContain('candidate-1/src/scripts/smoke.js');
|
|
expect(members).not.toContain('candidate-1/src/jest.config.js');
|
|
expect(members.some(member => member.startsWith('candidate-1/src/sim/'))).toBe(false);
|
|
|
|
const allowedTopLevel = new Set([
|
|
'.release-commit',
|
|
'.release-files.sha256',
|
|
'.release-id',
|
|
'.release-package-lock-sha256',
|
|
'.release-source-sha256',
|
|
'LICENSE',
|
|
'html',
|
|
'src'
|
|
]);
|
|
const actualTopLevel = new Set(
|
|
members
|
|
.filter(member => member.startsWith('candidate-1/'))
|
|
.map(member => member.slice('candidate-1/'.length).split('/')[0])
|
|
.filter(Boolean)
|
|
);
|
|
expect(actualTopLevel).toEqual(allowedTopLevel);
|
|
|
|
run('sha256sum', ['--check', path.join(first, 'candidate-1.tar.gz.sha256')], { cwd: first });
|
|
const extracted = path.join(root, 'extracted');
|
|
fs.mkdirSync(extracted);
|
|
run('tar', ['-xzf', artifactA, '-C', extracted]);
|
|
run('sha256sum', ['--check', '.release-files.sha256'], {
|
|
cwd: path.join(extracted, 'candidate-1')
|
|
});
|
|
const runtimePackage = JSON.parse(fs.readFileSync(
|
|
path.join(extracted, 'candidate-1/src/package.json'), 'utf8'
|
|
));
|
|
expect(runtimePackage.scripts).toEqual({
|
|
start: 'node index.js',
|
|
preflight: 'node scripts/preflight.js',
|
|
'db:migrate:clone': 'node scripts/migrate-disposable-clone.js'
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
['tracked modification', root => write(root, 'src/index.js', '// dirty\n')],
|
|
['untracked file', root => write(root, 'untracked.txt', 'not reviewed\n')]
|
|
])('refuses a dirty worktree: %s', (_label, dirty) => {
|
|
const root = fixture();
|
|
roots.push(root);
|
|
dirty(root);
|
|
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
|
|
cwd: root,
|
|
encoding: 'utf8'
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('worktree is dirty');
|
|
});
|
|
|
|
test('refuses tracked symlinks', () => {
|
|
const root = fixture();
|
|
roots.push(root);
|
|
fs.symlinkSync('index.js', path.join(root, 'src', 'linked.js'));
|
|
run('git', ['add', '.'], { cwd: root });
|
|
run('git', ['commit', '-qm', 'add symlink'], { cwd: root });
|
|
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
|
|
cwd: root,
|
|
encoding: 'utf8'
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('tracked symlink, gitlink, or unsafe file mode');
|
|
});
|
|
|
|
test('refuses tracked runtime environment files', () => {
|
|
const root = fixture();
|
|
roots.push(root);
|
|
write(root, '.env.production', 'SECRET=value\n');
|
|
run('git', ['add', '-f', '.env.production'], { cwd: root });
|
|
run('git', ['commit', '-qm', 'add forbidden env'], { cwd: root });
|
|
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
|
|
cwd: root,
|
|
encoding: 'utf8'
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('tracked non-example environment file is forbidden');
|
|
});
|
|
|
|
test('refuses unsafe release IDs and existing output', () => {
|
|
const root = fixture();
|
|
roots.push(root);
|
|
const output = path.join(root, 'out');
|
|
let result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
|
|
'--release-id', '../escape', '--output-dir', output
|
|
], { cwd: root, encoding: 'utf8' });
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('release ID must be');
|
|
|
|
build(root, output, 'candidate-1');
|
|
result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [
|
|
'--release-id', 'candidate-1', '--output-dir', output
|
|
], { cwd: root, encoding: 'utf8' });
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('refusing to overwrite');
|
|
});
|
|
|
|
test.each([
|
|
['missing', scripts => { delete scripts.preflight; }],
|
|
['empty', scripts => { scripts.preflight = ' '; }],
|
|
['non-string', scripts => { scripts.preflight = { command: 'node scripts/preflight.js' }; }]
|
|
])('refuses a %s required runtime package script', (_label, mutate) => {
|
|
const root = fixture();
|
|
roots.push(root);
|
|
const manifestPath = path.join(root, 'src/package.json');
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
mutate(manifest.scripts);
|
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`);
|
|
run('git', ['add', 'src/package.json'], { cwd: root });
|
|
run('git', ['commit', '-qm', 'break required runtime script'], { cwd: root });
|
|
|
|
const result = spawnSync(path.join(root, 'scripts/deploy/build-release-artifact.sh'), [], {
|
|
cwd: root,
|
|
encoding: 'utf8'
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('required runtime package script is absent or empty: preflight');
|
|
});
|
|
|
|
test('a losing concurrent build cannot remove the winning same-ID publication', async () => {
|
|
const root = fixture();
|
|
roots.push(root);
|
|
const output = path.join(root, 'out');
|
|
|
|
const results = await Promise.all([
|
|
buildAsync(root, output),
|
|
buildAsync(root, output),
|
|
buildAsync(root, output),
|
|
buildAsync(root, output)
|
|
]);
|
|
|
|
expect(results.filter(result => result.status === 0)).toHaveLength(1);
|
|
expect(results.filter(result => result.status !== 0)).toHaveLength(3);
|
|
const artifact = path.join(output, 'candidate-1.tar.gz');
|
|
const checksum = `${artifact}.sha256`;
|
|
expect(fs.existsSync(artifact)).toBe(true);
|
|
expect(fs.existsSync(checksum)).toBe(true);
|
|
run('sha256sum', ['--check', checksum], { cwd: output });
|
|
});
|
|
});
|