// Publishes a new version (readme §2): npm run release -- [--no-push] [--skip-checks] // // 1. checks (clean tree on main, tests and lint), bumps the version; // 2. builds the Windows exes (npm run dist); // 3. stores the game files (app.asar, its unpacked natives, assets/) with a manifest.json in a // parentless commit at refs/builds/v, next to the tag v of the source; // 4. pushes main, the tag and the build: installed games pick it up at their next launch. import { execFileSync, execSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, relative, resolve } from 'node:path'; import { BUILD_REF_PREFIX, compareVersions, isVersion, MANIFEST_FILE, type BuildManifest } from '../src/main/updates'; const root = resolve(import.meta.dirname, '..'); const resources = join(root, 'dist', 'win-unpacked', 'resources'); /** What a build carries: everything else in resources/ belongs to the installed runtime. */ const PAYLOAD = ['app.asar', 'app.asar.unpacked', 'assets']; const args = process.argv.slice(2); const version = args.find((a) => !a.startsWith('--')) ?? ''; const push = !args.includes('--no-push'); const checks = !args.includes('--skip-checks'); function run(cmd: string, cmdArgs: string[], env?: NodeJS.ProcessEnv): string { return execFileSync(cmd, cmdArgs, { cwd: root, encoding: 'utf8', env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'inherit'] }).trim(); } const git = (...a: string[]): string => run('git', a); function npm(...a: string[]): void { console.log(`\n> npm ${a.join(' ')}`); execSync(`npm ${a.join(' ')}`, { cwd: root, stdio: 'inherit' }); } function fail(message: string): never { console.error(`\n✗ ${message}`); process.exit(1); } function listFiles(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? listFiles(join(dir, e.name)) : [join(dir, e.name)], ); } /** Writes the payload into the git object store and returns the commit of the build. */ function commitBuild(): string { const electron = (JSON.parse(readFileSync(join(root, 'node_modules', 'electron', 'package.json'), 'utf8')) as { version: string }).version; const files = PAYLOAD.flatMap((p) => { const abs = join(resources, p); return statSync(abs).isDirectory() ? listFiles(abs) : [abs]; }); const manifest: BuildManifest = { version, electron, files: files.map((abs) => ({ path: relative(resources, abs).split('\\').join('/'), size: statSync(abs).size, sha256: createHash('sha256').update(readFileSync(abs)).digest('hex'), })), }; // A throwaway index, so the working tree and the real index stay untouched. const tmp = mkdtempSync(join(tmpdir(), 'goutermageddon-release-')); const env = { GIT_INDEX_FILE: join(tmp, 'index') }; try { const add = (path: string, blob: string): void => { run('git', ['update-index', '--add', '--cacheinfo', `100644,${blob},${path}`], env); }; for (const [i, f] of manifest.files.entries()) add(f.path, run('git', ['hash-object', '-w', '--no-filters', '--', files[i]])); const manifestBlob = execFileSync('git', ['hash-object', '-w', '--no-filters', '--stdin'], { cwd: root, input: JSON.stringify(manifest, null, 1), encoding: 'utf8' }).trim(); add(MANIFEST_FILE, manifestBlob); const tree = run('git', ['write-tree'], env); const size = manifest.files.reduce((s, f) => s + f.size, 0); console.log(`\nBuild v${version}: ${manifest.files.length} files, ${(size / 1e6).toFixed(1)} MB (Electron ${electron})`); return git('commit-tree', tree, '-m', `build v${version}`); } finally { rmSync(tmp, { recursive: true, force: true }); } } const current = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version; if (!isVersion(version)) fail(`usage: npm run release -- (current version: ${current})`); if (compareVersions(version, current) <= 0) fail(`${version} must be newer than the current version ${current}`); if (git('rev-parse', '--abbrev-ref', 'HEAD') !== 'main') fail('releases are made from main'); if (git('status', '--porcelain', '--untracked-files=no')) fail('commit or stash your changes first'); if (git('tag', '--list', `v${version}`)) fail(`tag v${version} already exists`); if (checks) { npm('test'); npm('run', 'lint'); } npm('version', version, '--no-git-tag-version'); try { npm('run', 'dist'); } catch { git('checkout', '--', 'package.json', 'package-lock.json'); fail('build failed; version bump undone'); } const build = commitBuild(); git('commit', '-q', '-m', `chore: release v${version}`, '--', 'package.json', 'package-lock.json'); git('tag', '-a', `v${version}`, '-m', `v${version}`); git('update-ref', `${BUILD_REF_PREFIX}${version}`, build); console.log(`Tag v${version} and ${BUILD_REF_PREFIX}${version} (${build.slice(0, 7)}) created.`); if (push) { console.log('\n> git push'); execFileSync('git', ['push', 'origin', 'main', `v${version}`, `${BUILD_REF_PREFIX}${version}`], { cwd: root, stdio: 'inherit' }); console.log(`\n✓ v${version} published: installed games update at their next launch.`); } else { console.log(`\nNot pushed. To publish: git push origin main v${version} ${BUILD_REF_PREFIX}${version}`); } console.log(`First install for a new player: dist/Goutermageddon-${version}-setup.exe (or -portable.exe).`);