Files
sanctuary-beta/scripts/pack.py
T
2026-09-08 12:49:42 +02:00

203 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""Validate the source pack or stage an installable packwiz directory (stdlib only)."""
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import sys
from urllib.parse import urlsplit
from urllib.request import urlopen
import zipfile
try:
import tomllib
except ImportError:
# macOS ships Python 3.9; use an installed modern Python without pip packages.
for name in ('python3.14', 'python3.13', 'python3.12', 'python3.11'):
candidate = shutil.which(name)
if candidate and Path(candidate).resolve() != Path(sys.executable).resolve():
os.execv(candidate, [candidate, *sys.argv])
raise SystemExit('Python 3.11+ is required for pack verification (tomllib).')
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / 'packwiz'
BOOTSTRAP_URL = ('https://github.com/packwiz/packwiz-installer-bootstrap/releases/'
'download/v0.0.3/packwiz-installer-bootstrap.jar')
BOOTSTRAP_SHA256 = 'a8fbb24dc604278e97f4688e82d3d91a318b98efc08d5dbfcbcbcab6443d116c'
def read_toml(path):
return tomllib.loads(path.read_text(encoding='utf-8'))
def sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def properties():
return dict(line.split('=', 1) for line in
(ROOT / 'gradle.properties').read_text().splitlines()
if line.strip() and not line.lstrip().startswith('#'))
def require(condition, message):
if not condition:
raise SystemExit(message)
def indexed_path(base, relative):
path = (base / relative).resolve()
require(path.is_relative_to(base.resolve()), 'Index path escapes pack: ' + relative)
require(path.is_file(), 'Missing indexed file: ' + str(path))
return path
def check_index(base):
pack = read_toml(base / 'pack.toml')
index_path = indexed_path(base, pack['index']['file'])
require(pack['index']['hash-format'] == 'sha256', 'Pack index must use sha256')
require(sha256(index_path) == pack['index']['hash'], 'Stale pack hash; run packwiz refresh')
index = read_toml(index_path)
require(index['hash-format'] == 'sha256', 'Index files must use sha256')
entries = index.get('files', [])
require(len({entry['file'] for entry in entries}) == len(entries), 'Duplicate index entry')
for entry in entries:
path = indexed_path(base, entry['file'])
require(sha256(path) == entry['hash'], 'Stale index hash: ' + entry['file'])
if entry.get('metafile'):
metadata = read_toml(path)
download = metadata['download']
require(download['url'].startswith('https://'), 'Dependency URL must use HTTPS')
algorithm = download['hash-format']
require(algorithm in ('sha256', 'sha512'), 'Dependency needs SHA-256/512')
length = 64 if algorithm == 'sha256' else 128
require(re.fullmatch('[0-9a-f]{' + str(length) + '}', download['hash']),
'Invalid dependency download hash')
return pack, entries
def check():
values = properties()
pack, entries = check_index(SOURCE)
require(pack['versions']['minecraft'] == values['minecraft_version'], 'Minecraft version drift')
require(pack['versions']['fabric'] == values['loader_version'], 'Fabric Loader version drift')
require(pack['version'] == values['pack_version'], 'Pack version drift')
api = read_toml(SOURCE / 'mods/fabric-api.pw.toml')
require(api['filename'] == 'fabric-api-' + values['fabric_api_version'] + '.jar',
'Fabric API version drift')
require(any(e['file'] == 'mods/fabric-api.pw.toml' and e.get('metafile') for e in entries),
'Fabric API is missing from the index')
print('Pack versions and index hashes verified.')
return values, entries
def https_url(value):
parsed = urlsplit(value)
require(parsed.scheme == 'https' and parsed.hostname and not parsed.username
and not parsed.password and not parsed.fragment
and not re.search(r'[\s"\'\\]', value), 'Expected a public HTTPS URL')
return value
def assemble(jar_url=None):
values, entries = check()
jar = ROOT / 'mods/sanctuary/build/libs' / ('sanctuary-' + values['mod_version'] + '.jar')
require(jar.is_file(), 'Build Sanctuary before assembling the pack: ' + str(jar))
destination = ROOT / ('build/packwiz-release' if jar_url else 'build/packwiz')
# Only this generated staging directory is replaced; never touch a game instance.
if destination.exists():
shutil.rmtree(destination)
destination.mkdir(parents=True)
staged_entries = []
for entry in entries:
source = indexed_path(SOURCE, entry['file'])
target = destination / entry['file']
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
staged_entries.append(entry)
target = destination / 'mods' / ('sanctuary.pw.toml' if jar_url else jar.name)
target.parent.mkdir(parents=True, exist_ok=True)
if jar_url:
# Keep this metadata path stable so packwiz replaces the previous JAR.
target.write_text('\n'.join([
'name = "Sanctuary"', 'filename = ' + json.dumps(jar.name),
'side = "both"', '', '[download]',
'url = ' + json.dumps(https_url(jar_url)), 'hash-format = "sha256"',
'hash = ' + json.dumps(sha256(jar)), '',
]), encoding='utf-8')
staged_entries.append({'file': 'mods/sanctuary.pw.toml',
'hash': sha256(target), 'metafile': True})
else:
shutil.copy2(jar, target)
staged_entries.append({'file': 'mods/' + jar.name, 'hash': sha256(target)})
lines = ['hash-format = "sha256"', '']
for entry in sorted(staged_entries, key=lambda e: e['file']):
lines.append('[[files]]')
for key, value in entry.items():
lines.append(key + ' = ' + json.dumps(value))
lines.append('')
index_path = destination / 'index.toml'
index_path.write_text('\n'.join(lines), encoding='utf-8')
manifest = (SOURCE / 'pack.toml').read_text(encoding='utf-8')
manifest, count = re.subn(r'(?m)^hash = "[a-f0-9]+"$',
'hash = "' + sha256(index_path) + '"', manifest)
require(count == 1, 'Expected a single pack index hash')
(destination / 'pack.toml').write_text(manifest, encoding='utf-8')
check_index(destination)
print('Pack staged at ' + str(destination))
def prism(pack_url):
"""Create an importable launcher template; never modify an installed instance."""
https_url(pack_url)
values, _ = check()
with urlopen(BOOTSTRAP_URL, timeout=60) as response:
bootstrap = response.read()
require(hashlib.sha256(bootstrap).hexdigest() == BOOTSTRAP_SHA256,
'Unexpected packwiz bootstrap SHA-256')
command = ('"$INST_JAVA" -jar "$INST_MC_DIR/packwiz-installer-bootstrap.jar" '
'--bootstrap-main-jar "$INST_MC_DIR/packwiz-installer.jar" '
'--pack-folder "$INST_MC_DIR" --multimc-folder "$INST_DIR" '
+ pack_url)
# Prism instance.cfg uses Qt INI escaping for quotation marks.
config = '\n'.join([
'[General]', 'ConfigVersion=1.3', 'InstanceType=OneSix',
'name=Sanctuary Beta', 'iconKey=default', 'AutomaticJava=true',
'OverrideJavaLocation=false', 'IgnoreJavaCompatibility=false',
'OverrideMemory=true', 'MinMemAlloc=512', 'MaxMemAlloc=4096',
'ManagedPack=false', 'OverrideCommands=true', 'LogPrePostOutput=true',
'PreLaunchCommand=' + command.replace('"', '\\"'),
'notes=Sanctuary Beta : mise a jour packwiz a chaque lancement.', '',
])
components = {
'formatVersion': 1,
'components': [
{'uid': 'net.minecraft', 'version': values['minecraft_version'], 'important': True},
{'uid': 'net.fabricmc.fabric-loader', 'version': values['loader_version']},
],
}
destination = ROOT / 'build/Sanctuary-Prism-auto-update.zip'
destination.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('instance.cfg', config)
archive.writestr('mmc-pack.json', json.dumps(components, indent=2) + '\n')
archive.writestr('minecraft/packwiz-installer-bootstrap.jar', bootstrap)
print('Prism auto-update template: ' + str(destination))
print('Pre-launch command: ' + command)
if __name__ == '__main__':
require((len(sys.argv) == 2 and sys.argv[1] in ('check', 'assemble'))
or (len(sys.argv) == 3 and sys.argv[1] in ('release', 'prism')),
'Usage: python3 scripts/pack.py check|assemble|release <jar-https-url>|prism <pack-https-url>')
if sys.argv[1] == 'release':
assemble(https_url(sys.argv[2]))
elif sys.argv[1] == 'prism':
prism(sys.argv[2])
elif sys.argv[1] == 'assemble':
assemble()
else:
check()