326 lines
23 KiB
JavaScript
326 lines
23 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { isIP } from 'node:net';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { parseDocument, stringify as yamlStringify } from 'yaml';
|
|
import { parse as tomlParse } from 'smol-toml';
|
|
|
|
const fail = message => { throw new Error(message); };
|
|
const hash = (data, algorithm = 'sha256') => crypto.createHash(algorithm).update(data).digest('hex');
|
|
const fileHash = file => hash(fs.readFileSync(file));
|
|
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
const requiredString = (value, label) => typeof value === 'string' && value.length > 0 ? value : fail(`${label} must be a nonempty string`);
|
|
const secretName = name => (/^\.env(?:\..*)?$/.test(name) && name !== '.env.example') || /^(id_rsa|id_ed25519)$/.test(name);
|
|
|
|
function run(command, args, { cwd, env = process.env, log = () => {} } = {}) {
|
|
try {
|
|
const output = execFileSync(command, args, { cwd, env, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
if (output.trim()) log(output.trim());
|
|
return output;
|
|
} catch (error) {
|
|
fail(`${path.basename(command)} failed: ${error.stderr?.toString().trim() || error.message}`);
|
|
}
|
|
}
|
|
|
|
function yaml(text, label) {
|
|
const doc = parseDocument(text, { uniqueKeys: true });
|
|
if (doc.errors.length || doc.warnings.length) fail(`${label}: ${[...doc.errors, ...doc.warnings].map(e => e.message).join('; ')}`);
|
|
return doc.toJS({ maxAliasCount: 50 });
|
|
}
|
|
|
|
function keys(value, allowed, label) {
|
|
if (!plain(value)) fail(`${label} must be a mapping`);
|
|
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`Unknown ${label} key: ${key}`);
|
|
for (const key of allowed) if (!(key in value)) fail(`Missing ${label}.${key}`);
|
|
}
|
|
|
|
export function validateConfig(config) {
|
|
keys(config, ['schema_version', 'pack', 'build', 'server', 'deploy'], 'config');
|
|
if (config.schema_version !== 1) fail('Unsupported config schema_version');
|
|
keys(config.pack, ['directory'], 'pack');
|
|
keys(config.build, ['output_directory'], 'build');
|
|
keys(config.server, ['host', 'directory', 'memory', 'port'], 'server');
|
|
keys(config.deploy, ['transport', 'healthcheck_timeout'], 'deploy');
|
|
requiredString(config.pack.directory, 'pack.directory');
|
|
requiredString(config.build.output_directory, 'build.output_directory');
|
|
if (!/^[1-9]\d*[MG]$/.test(config.server.memory)) fail('server.memory must use M or G, for example 2G');
|
|
if (!Number.isInteger(config.server.port) || config.server.port < 1 || config.server.port > 65535) fail('Invalid server.port');
|
|
for (const key of ['host', 'directory']) if (config.server[key] !== null) requiredString(config.server[key], `server.${key}`);
|
|
if ((config.server.host === null) !== (config.server.directory === null)) fail('Set both server.host and server.directory, or leave both null');
|
|
if (config.server.directory !== null && !config.server.directory.startsWith('/')) fail('Remote server.directory must be absolute');
|
|
if (config.deploy.transport !== 'ssh') fail('Only SSH deploy transport is supported');
|
|
if (!Number.isInteger(config.deploy.healthcheck_timeout) || config.deploy.healthcheck_timeout < 1) fail('Invalid healthcheck_timeout');
|
|
return config;
|
|
}
|
|
|
|
function safeRelative(value) {
|
|
return typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..');
|
|
}
|
|
|
|
function inside(root, value) {
|
|
const result = path.resolve(root, requiredString(value, 'path'));
|
|
const relative = path.relative(root, result);
|
|
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail(`Path must be inside repository: ${value}`);
|
|
let current = root;
|
|
for (const part of relative.split(path.sep)) {
|
|
current = path.join(current, part);
|
|
try {
|
|
if (fs.lstatSync(current).isSymbolicLink()) fail(`Symlinks are not allowed: ${value}`);
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function files(directory) {
|
|
const result = [];
|
|
function walk(current) {
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const file = path.join(current, entry.name);
|
|
if (secretName(entry.name)) fail(`Known secret filename is not allowed in source: ${file}`);
|
|
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) fail(`Only regular files/directories are allowed: ${file}`);
|
|
if (entry.isDirectory()) walk(file);
|
|
else {
|
|
const relative = path.relative(directory, file).split(path.sep).join('/');
|
|
if (!safeRelative(relative)) fail(`Invalid file path: ${relative}`);
|
|
result.push(relative);
|
|
}
|
|
}
|
|
}
|
|
walk(directory);
|
|
return result.sort();
|
|
}
|
|
|
|
function readLock(file) {
|
|
const result = {};
|
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
const match = /^([A-Z][A-Z0-9_]*)=(\S+)$/.exec(line);
|
|
if (!match || match[1] in result) fail('Invalid or duplicate toolchain lock entry');
|
|
result[match[1]] = match[2];
|
|
}
|
|
for (const key of ['PACKWIZ_BINARY_SHA256', 'COMPOSE_SHA256']) if (!/^[a-f0-9]{64}$/.test(result[key])) fail(`Invalid ${key}`);
|
|
if (!/^[a-f0-9]{40}$/.test(result.PACKWIZ_COMMIT)) fail('Invalid PACKWIZ_COMMIT');
|
|
if (!/^itzg\/minecraft-server@sha256:[a-f0-9]{64}$/.test(result.MC_IMAGE)) fail('MC_IMAGE must be pinned by digest');
|
|
requiredString(result.COMPOSE_VERSION, 'COMPOSE_VERSION');
|
|
return result;
|
|
}
|
|
|
|
function validatePack(directory, compatibility, image) {
|
|
const tree = files(directory);
|
|
const pack = tomlParse(fs.readFileSync(path.join(directory, 'pack.toml'), 'utf8'));
|
|
for (const key of ['name', 'version']) if (typeof pack[key] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pack[key])) fail(`Invalid pack ${key}`);
|
|
if (pack['pack-format'] !== 'packwiz:1.1.0') fail('Unsupported pack format');
|
|
const minecraft = requiredString(pack.versions?.minecraft, 'Minecraft version');
|
|
const loaders = Object.keys(pack.versions).filter(key => key !== 'minecraft');
|
|
if (loaders.length !== 1) fail('Specify exactly one loader');
|
|
const loader = loaders[0], loaderVersion = requiredString(pack.versions[loader], 'loader version');
|
|
const verified = compatibility.verified?.find(v => v.minecraft === minecraft && v.loader === loader && v.loader_version === loaderVersion && v.image === image);
|
|
if (!verified || verified.java_major !== 21) fail('Minecraft/loader/image combination has not been verified by the spike');
|
|
if (!safeRelative(pack.index?.file) || pack.index['hash-format'] !== 'sha256') fail('Pack index must have a safe path and SHA256');
|
|
const indexFile = path.join(directory, pack.index.file);
|
|
if (fileHash(indexFile) !== pack.index.hash) fail('Stale pack index hash: run packwiz refresh explicitly');
|
|
const index = tomlParse(fs.readFileSync(indexFile, 'utf8'));
|
|
if (index['hash-format'] !== 'sha256' || !Array.isArray(index.files)) fail('Unsupported or missing index files');
|
|
const targets = new Set(), modIds = new Set(), indexed = new Set();
|
|
for (const entry of index.files) {
|
|
if (!safeRelative(entry.file) || indexed.has(entry.file)) fail(`Unsafe or duplicate indexed path: ${entry.file}`);
|
|
indexed.add(entry.file);
|
|
const file = path.join(directory, entry.file);
|
|
if (!tree.includes(entry.file) || fileHash(file) !== entry.hash) fail(`Missing or stale indexed file: ${entry.file}; run packwiz refresh explicitly`);
|
|
if (entry.metafile) {
|
|
const mod = tomlParse(fs.readFileSync(file, 'utf8'));
|
|
if (!safeRelative(mod.filename) || mod.filename.includes('/')) fail(`Invalid mod filename: ${entry.file}`);
|
|
if (!['client', 'server', 'both'].includes(mod.side)) fail(`Explicit side metadata required: ${entry.file}`);
|
|
const target = path.posix.join(path.posix.dirname(entry.file), mod.filename);
|
|
if (targets.has(target)) fail(`Duplicate mod target: ${target}`);
|
|
targets.add(target);
|
|
const modId = mod.update?.modrinth?.['mod-id'];
|
|
if (modId && modIds.has(modId)) fail(`Duplicate Modrinth project: ${modId}`);
|
|
if (modId) modIds.add(modId);
|
|
const download = mod.download;
|
|
let url;
|
|
try { url = new URL(download?.url); } catch { fail(`Missing download URL: ${entry.file}`); }
|
|
if (url.protocol !== 'https:' || url.username || url.password) fail(`Download URL must be HTTPS without credentials: ${entry.file}`);
|
|
const length = { sha512: 128, sha256: 64, sha1: 40, md5: 32 }[download['hash-format']];
|
|
if (!length || !new RegExp(`^[a-f0-9]{${length}}$`).test(download.hash)) fail(`Invalid download hash: ${entry.file}`);
|
|
}
|
|
}
|
|
return { pack, minecraft, loader, loaderVersion, verified, tree };
|
|
}
|
|
|
|
function generatedServer(root, release, artifact, config, lock) {
|
|
const source = path.join(root, 'server');
|
|
const compose = yaml(fs.readFileSync(path.join(source, 'compose.yaml'), 'utf8'), 'server compose');
|
|
const service = compose.services?.minecraft;
|
|
if (!service || Object.keys(compose.services).length !== 1) fail('Expected one minecraft Compose service');
|
|
if (service.environment?.TYPE !== 'MODRINTH' || service.environment?.ONLINE_MODE !== 'false' || service.environment?.ENFORCE_SECURE_PROFILE !== 'false') fail('Compose must use MODRINTH and offline authentication');
|
|
service.image = lock.MC_IMAGE;
|
|
service.ports = [`\${MC_BIND_ADDRESS:-127.0.0.1}:\${MC_PORT:-${config.server.port}}:25565`];
|
|
service.environment.MEMORY = `\${MC_MEMORY:-${config.server.memory}}`;
|
|
service.environment.MODRINTH_MODPACK = `/modpacks/${artifact}`;
|
|
service.environment.SYNC_SKIP_NEWER_IN_DESTINATION = 'false';
|
|
service.volumes = [
|
|
{ type: 'bind', source: '${MC_ROOT:?Set absolute server root}/runtime', target: '/data' },
|
|
{ type: 'bind', source: '${MC_RELEASE_DIR:?Set absolute release directory}', target: '/modpacks', read_only: true },
|
|
{ type: 'bind', source: '${MC_RELEASE_DIR:?Set absolute release directory}/server/config', target: '/config', read_only: true }
|
|
];
|
|
const destination = path.join(release, 'server');
|
|
fs.mkdirSync(destination);
|
|
fs.writeFileSync(path.join(destination, 'compose.yaml'), yamlStringify(compose));
|
|
// Allowlist local settings; never copy server/.env or deployment target.
|
|
const example = readLockExample(path.join(source, '.env.example'));
|
|
example.MC_EULA = 'FALSE';
|
|
example.MC_MEMORY = config.server.memory;
|
|
example.MC_PORT = String(config.server.port);
|
|
fs.writeFileSync(path.join(destination, '.env.example'), '# Accept Minecraft EULA before setting MC_EULA=TRUE.\n' + Object.entries(example).map(([key, value]) => `${key}=${value}\n`).join(''));
|
|
fs.cpSync(path.join(source, 'config'), path.join(destination, 'config'), { recursive: true });
|
|
fs.writeFileSync(path.join(destination, 'README.md'), `# Immutable server release\n\nCopy server/.env.example to a local settings file outside this release.\nAccept the Minecraft EULA before setting MC_EULA=TRUE.\n\nSet MC_ROOT to an absolute server root and MC_RELEASE_DIR to this release.\nUse the same Compose project name for all releases:\n\n\`\`\`bash\ndocker compose --project-name minecraft-lab-spike --env-file /absolute/local-settings.env -f "$MC_RELEASE_DIR/server/compose.yaml" config --quiet\n\`\`\`\n\nThis release uses offline authentication. MC_BIND_ADDRESS selects the IPv4 interface; default is 127.0.0.1, use 0.0.0.0 for remote LAN access.\nserver/config overrides corresponding pack settings at startup.\nRuntime-generated server.properties is configured through Compose environment.\n`);
|
|
return compose;
|
|
}
|
|
|
|
function readLockExample(file) {
|
|
const result = {};
|
|
const allowed = ['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_BIND_ADDRESS'];
|
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
const match = /^([A-Z_]+)=([A-Za-z0-9.]+)$/.exec(line);
|
|
if (!match || !allowed.includes(match[1]) || match[1] in result) fail('Unexpected setting in server/.env.example');
|
|
result[match[1]] = match[2];
|
|
}
|
|
if (result.MC_BIND_ADDRESS && isIP(result.MC_BIND_ADDRESS) !== 4) fail('Invalid MC_BIND_ADDRESS in env example');
|
|
return result;
|
|
}
|
|
|
|
function managedFiles(extracted, index, release) {
|
|
const sides = { client: new Map(), server: new Map() };
|
|
for (const file of index.files ?? []) {
|
|
if (!safeRelative(file.path) || !plain(file.hashes) || !file.hashes.sha512 || !file.hashes.sha1) fail('Invalid exported mod metadata');
|
|
for (const side of ['client', 'server']) if (file.env?.[side] !== 'unsupported') {
|
|
if (sides[side].has(file.path)) fail(`Duplicate exported target: ${file.path}`);
|
|
sides[side].set(file.path, { path: file.path, owner: 'pack', policy: 'replace', hashes: file.hashes });
|
|
}
|
|
}
|
|
for (const prefix of ['overrides', 'client-overrides', 'server-overrides']) {
|
|
const directory = path.join(extracted, prefix);
|
|
if (!fs.existsSync(directory)) continue;
|
|
for (const file of files(directory)) for (const side of ['client', 'server']) {
|
|
if (prefix !== 'overrides' && prefix !== `${side}-overrides`) continue;
|
|
sides[side].set(file, { path: file, owner: 'pack', policy: 'replace', hashes: { sha256: fileHash(path.join(directory, file)) } });
|
|
}
|
|
}
|
|
const directory = path.join(release, 'server/config');
|
|
for (const file of files(directory)) {
|
|
const target = `config/${file}`;
|
|
const previous = sides.server.get(target);
|
|
sides.server.set(target, { path: target, owner: 'server', policy: 'replace', hashes: { sha256: fileHash(path.join(directory, file)) }, ...(previous ? { overrides: previous.owner } : {}) });
|
|
}
|
|
return Object.fromEntries(Object.entries(sides).map(([side, entries]) => [side, [...entries.values()].sort((a, b) => a.path.localeCompare(b.path))]));
|
|
}
|
|
|
|
export function packwizContext({ root, configFile = 'platform/config.yaml' }) {
|
|
root = fs.realpathSync(root);
|
|
const config = validateConfig(yaml(fs.readFileSync(inside(root, configFile), 'utf8'), 'platform config'));
|
|
if (path.isAbsolute(config.pack.directory)) fail('Pack directory must be relative to repository');
|
|
const source = inside(root, config.pack.directory);
|
|
inside(root, path.join(config.pack.directory, 'pack.toml'));
|
|
if (!fs.statSync(path.join(source, 'pack.toml')).isFile()) fail('pack.toml must be a regular file');
|
|
const lock = readLock(inside(root, 'toolchain.lock.env'));
|
|
const executable = path.join(root, '.tools/packwiz/packwiz');
|
|
if (!fs.existsSync(executable)) fail('Tools missing: run scripts/spike-tools.sh');
|
|
if (fileHash(executable) !== lock.PACKWIZ_BINARY_SHA256) fail('Pinned packwiz checksum mismatch');
|
|
return { source, executable, args: ['--cache', path.join(root, '.tools/packwiz/cache'), '--config', path.join(root, '.tools/packwiz/config.toml')] };
|
|
}
|
|
|
|
export function build({ root, configFile = 'platform/config.yaml', releaseOnly = false, outputDirectory, log = console.log }) {
|
|
root = fs.realpathSync(root);
|
|
const configuration = inside(root, configFile);
|
|
const config = validateConfig(yaml(fs.readFileSync(configuration, 'utf8'), 'platform config'));
|
|
if (path.isAbsolute(config.pack.directory) || path.isAbsolute(config.build.output_directory)) fail('Pack/output directories must be relative to repository');
|
|
const source = inside(root, config.pack.directory), output = inside(root, outputDirectory ?? config.build.output_directory);
|
|
// Build may never write into source, runtime, tooling, or a parent thereof.
|
|
const protectedPaths = [source, ...['server', 'platform', 'scripts', 'runtime', '.tools', '.git', 'node_modules', 'docs', 'examples', 'testdata'].map(v => path.join(root, v))];
|
|
for (const protectedPath of protectedPaths) if (output === protectedPath || output.startsWith(protectedPath + path.sep) || protectedPath.startsWith(output + path.sep)) fail('Output overlaps a protected/source directory');
|
|
for (const value of ['server/compose.yaml', 'server/.env.example', 'server/config', 'toolchain.lock.env', 'platform/compatibility.json', 'package-lock.json']) inside(root, value);
|
|
const lock = readLock(path.join(root, 'toolchain.lock.env'));
|
|
const compatibility = JSON.parse(fs.readFileSync(path.join(root, 'platform/compatibility.json'), 'utf8'));
|
|
const validated = validatePack(source, compatibility, lock.MC_IMAGE);
|
|
const { pack, minecraft, loader, loaderVersion, verified } = validated;
|
|
const toolFiles = { packwiz: path.join(root, '.tools/packwiz/packwiz'), compose: path.join(root, '.tools/docker-compose') };
|
|
for (const [tool, file] of Object.entries(toolFiles)) {
|
|
if (!fs.existsSync(file)) fail('Tools missing: run scripts/spike-tools.sh');
|
|
if (fileHash(file) !== lock[tool === 'packwiz' ? 'PACKWIZ_BINARY_SHA256' : 'COMPOSE_SHA256']) fail(`Pinned ${tool} checksum mismatch`);
|
|
}
|
|
const gitCommit = run('git', ['rev-parse', 'HEAD'], { cwd: root }).trim();
|
|
const tracked = run('git', ['ls-files', '-z'], { cwd: root }).split('\0').filter(Boolean);
|
|
const outputRelative = path.relative(root, output).split(path.sep).join('/');
|
|
if (tracked.some(file => file !== `${outputRelative}/.gitkeep` && (file === outputRelative || file.startsWith(outputRelative + '/')))) fail('Output directory contains tracked files');
|
|
const statusArgs = ['status', '--porcelain', '--untracked-files=all', '--', '.', `:(exclude)${outputRelative}`];
|
|
const gitStatus = run('git', statusArgs, { cwd: root });
|
|
const gitDirty = gitStatus.length > 0;
|
|
if (tracked.some(file => file.split('/').some(secretName))) fail('Known secret path is tracked by Git');
|
|
if (releaseOnly && gitDirty) fail('--release requires a clean Git worktree');
|
|
const releaseName = `${pack.name}-${pack.version}`, artifact = `${releaseName}.mrpack`, destination = path.join(output, releaseName);
|
|
if (fs.existsSync(destination)) fail(`Release already exists: ${destination}; choose a new pack version or output directory`);
|
|
fs.mkdirSync(output, { recursive: true });
|
|
const lockDirectory = path.join(output, `.build-${releaseName}.lock`);
|
|
try { fs.mkdirSync(lockDirectory); } catch { fail(`Build is already locked: ${lockDirectory}`); }
|
|
let temporary;
|
|
try {
|
|
temporary = fs.mkdtempSync(path.join(output, `.build-${releaseName}-`));
|
|
const copied = path.join(temporary, 'source'), release = path.join(temporary, 'release');
|
|
fs.cpSync(source, copied, { recursive: true });
|
|
fs.mkdirSync(release);
|
|
const inputFiles = [...validated.tree.map(file => path.join(source, file)), ...['server/compose.yaml', 'server/.env.example', 'toolchain.lock.env', 'platform/compatibility.json', 'package-lock.json', 'package.json', 'platform/build.mjs', 'platform/commands.mjs', 'platform/runtime.mjs', 'platform/backups.mjs', 'platform/health.mjs', 'platform/deployment.mjs', 'platform/ssh-deploy.mjs', 'platform/rollback.mjs', 'scripts/deploy-worker.mjs', 'scripts/mcpack.mjs'].map(file => path.join(root, file)), ...files(path.join(root, 'server/config')).map(file => path.join(root, 'server/config', file)), configuration];
|
|
const inputs = [...new Set(inputFiles)].sort().map(file => ({ path: path.relative(root, file).split(path.sep).join('/'), sha256: fileHash(file) }));
|
|
const copiedHashes = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
|
|
for (const [file, sha256] of copiedHashes) if (sha256 !== fileHash(path.join(source, file))) fail(`Source changed while copying: ${file}`);
|
|
const packwizArgs = ['--cache', path.join(root, '.tools/packwiz/cache'), '--config', path.join(root, '.tools/packwiz/config.toml')];
|
|
run(toolFiles.packwiz, [...packwizArgs, 'refresh'], { cwd: copied, log });
|
|
const refreshed = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
|
|
if (JSON.stringify(copiedHashes) !== JSON.stringify(refreshed)) fail('Stale source: run packwiz refresh explicitly; build never changes source');
|
|
run(toolFiles.packwiz, [...packwizArgs, 'modrinth', 'export', '--output', path.join(release, artifact)], { cwd: copied, log });
|
|
const exportedTree = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
|
|
if (JSON.stringify(copiedHashes) !== JSON.stringify(exportedTree)) fail('Packwiz export changed source metadata');
|
|
const archive = path.join(release, artifact), extracted = path.join(temporary, 'extracted');
|
|
const zipPaths = run('bsdtar', ['-tf', archive]).trim().split('\n').filter(Boolean);
|
|
for (const file of zipPaths) if (!safeRelative(file.replace(/\/$/, ''))) fail(`Unsafe archive path: ${file}`);
|
|
fs.mkdirSync(extracted);
|
|
run('bsdtar', ['--no-same-owner', '-xf', archive, '-C', extracted]);
|
|
files(extracted);
|
|
const exported = JSON.parse(fs.readFileSync(path.join(extracted, 'modrinth.index.json'), 'utf8'));
|
|
if (exported.name !== pack.name || exported.versionId !== pack.version || exported.dependencies?.minecraft !== minecraft || exported.dependencies?.[loader] !== loaderVersion || Object.keys(exported.dependencies).length !== 2) fail('Export metadata differs from source pack');
|
|
generatedServer(root, release, artifact, config, lock);
|
|
const composeEnv = { PATH: process.env.PATH, HOME: process.env.HOME, MC_ROOT: root, MC_RELEASE_DIR: release };
|
|
run(toolFiles.compose, ['--project-name', 'minecraft-lab-spike', '--env-file', path.join(release, 'server/.env.example'), '-f', path.join(release, 'server/compose.yaml'), 'config', '--quiet'], { env: composeEnv });
|
|
const managed = managedFiles(extracted, exported, release);
|
|
const manifest = {
|
|
schema_version: 1,
|
|
pack: { name: pack.name, version: pack.version, minecraft, loader: { id: loader, version: loaderVersion } },
|
|
artifact,
|
|
git: { commit: gitCommit, dirty: gitDirty },
|
|
tools: { packwiz: { commit: lock.PACKWIZ_COMMIT, sha256: lock.PACKWIZ_BINARY_SHA256 }, compose: { version: lock.COMPOSE_VERSION, sha256: lock.COMPOSE_SHA256 }, node: process.version, npm_lock_sha256: fileHash(path.join(root, 'package-lock.json')) },
|
|
server: { image: lock.MC_IMAGE, java_major: verified.java_major, memory: config.server.memory, port: config.server.port, compose_project: 'minecraft-lab-spike' },
|
|
source_files: inputs,
|
|
managed_files: managed,
|
|
runtime_policy: { unmanaged: 'preserve', defaultconfigs: 'seed-only' },
|
|
release_files: files(release).map(file => ({ path: file, sha256: fileHash(path.join(release, file)) }))
|
|
};
|
|
fs.writeFileSync(path.join(release, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
const checksums = files(release).map(file => `${fileHash(path.join(release, file))} ${file}\n`).join('');
|
|
fs.writeFileSync(path.join(release, 'SHA256SUMS'), checksums);
|
|
run('sha256sum', ['-c', 'SHA256SUMS'], { cwd: release, log });
|
|
for (const input of inputs) if (fileHash(path.join(root, input.path)) !== input.sha256) fail(`Source changed during build: ${input.path}`);
|
|
for (const [tool, file] of Object.entries(toolFiles)) if (fileHash(file) !== lock[tool === 'packwiz' ? 'PACKWIZ_BINARY_SHA256' : 'COMPOSE_SHA256']) fail(`Pinned ${tool} changed during build`);
|
|
if (run('git', ['rev-parse', 'HEAD'], { cwd: root }).trim() !== gitCommit || run('git', statusArgs, { cwd: root }) !== gitStatus) fail('Git state changed during build');
|
|
if (fs.existsSync(destination)) fail('Release destination appeared during build');
|
|
fs.renameSync(release, destination);
|
|
return destination;
|
|
} finally {
|
|
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
|
|
fs.rmdirSync(lockDirectory);
|
|
}
|
|
}
|