Files
EscapistsReintroduced/tools/native_package.py
T
jakub cab998fb64 Combine Workshop addon and fix prison startup
Merge owned systems into one addon while preserving resource identities. Gate player admission on the replicated prison scene, restore the original framed door, and hold enemy AI until escape. Correct scenario branding and author metadata, include publishing assets, and retain regression and native verification evidence.
2026-09-20 16:42:52 +02:00

611 lines
33 KiB
Python

#!/usr/bin/env python3
"""Build unqualified native PAK candidates in a fresh private /tmp session.
Never publish. Native package contents and consumption remain separate checks.
"""
import argparse
import fcntl
import hashlib
import io
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import time
import uuid
import zipfile
from source_bundle import build, inventory, PROJECTS
from headless_game import assert_no_game, assert_prefix_idle, cleanup_owned_run
from native_report import analyze
from external_addons import policy as external_policy, installed as external_installed, prepare_profile
ROOT = Path(__file__).resolve().parents[1]
ORDER = tuple(PROJECTS)
# Record the direct Proton/Wine status independently of Gamescope's exit status.
# This helper lives only in the owned session and never interprets shell text.
CHILD_RECORDER = """import json, os, subprocess, sys
from pathlib import Path
command = sys.argv[3:]
environment = os.environ.copy()
environment.update(json.loads(Path(sys.argv[2]).read_text()))
code = subprocess.call(command, env=environment)
with Path(sys.argv[1]).open('x') as output:
json.dump({'command': command, 'exit_code': code}, output)
sys.exit(code)
"""
def digest(path):
h = hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda: stream.read(1024 * 1024), b''):
h.update(block)
return h.hexdigest()
def save(path, value):
path.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n')
def project_order(project):
if project not in PROJECTS:
raise ValueError('Unknown successor project')
needed = {project, *PROJECTS[project][2]}
return [name for name in ORDER if name in needed]
def external_request(project, source, root):
needs_external = project in {'core'}
if needs_external and source is None:
raise ValueError('Combined mod builds require --external-addons with the audited installed RHS/WCS/BMP closure')
if source is not None and not needs_external:
raise ValueError('Unknown project; only the combined core addon is supported')
if source is None:
return None
source = Path(source).resolve()
if not source.is_dir():
raise ValueError('External addon source directory missing')
policy_path = root/'docs/external-addons.json'
if policy_path.is_symlink() or not policy_path.is_file():
raise ValueError('External policy must be a regular repository file')
before = digest(policy_path)
value = external_policy(root)
if digest(policy_path) != before:
raise ValueError('External policy changed while reading')
return {'source': source, 'policy': value, 'policy_path': policy_path,
'policy_sha256': before}
def capture_external(context, session):
cache = {}
context['records'] = external_installed(context['source'], context['policy'], cache)
context['cache_path'] = session/'external-source-cache.json'
save(context['cache_path'], cache)
context['cache_sha256'] = digest(context['cache_path'])
save(session/'external-addons-policy.json', context['policy'])
save(session/'external-addons-before.json', context['records'])
verify_external(context)
def verify_external(context):
context['last_records'] = None
if context['cache_path'].is_symlink() or digest(context['cache_path']) != context['cache_sha256']:
raise ValueError('External fingerprint cache changed during package attempt')
if context['policy_path'].is_symlink() or digest(context['policy_path']) != context['policy_sha256']:
raise ValueError('External dependency policy changed during package attempt')
cache = json.loads(context['cache_path'].read_text())
current = external_installed(context['source'], context['policy'], cache)
context['last_records'] = current
if current != context['records']:
raise ValueError('External addon inputs changed during package attempt')
return current
def verify_package_profile(profile, steam, records):
addons = profile/'addons'
expected = {'ArmaReforger', *('ERRI_'+guid for guid in records)}
if addons.is_symlink() or not addons.is_dir() or addons.resolve() != addons:
raise ValueError('Private package addon directory changed')
names = {path.name for path in addons.iterdir()}
if names - {'saves'} != expected:
raise ValueError('Private package profile contains missing or unrelated addons')
saves = addons/'saves'
if saves.exists() or saves.is_symlink():
if saves.is_symlink() or not saves.is_dir():
raise ValueError('Unexpected private package saves entry')
targets = {'ArmaReforger': steam/'steamapps/common/Arma Reforger/addons/data'}
targets.update({'ERRI_'+guid: Path(entry['path']) for guid, entry in records.items()})
for name, target in targets.items():
link = addons/name
if not link.is_symlink() or link.resolve() != target.resolve():
raise ValueError('Private package dependency link changed: '+name)
def expose_external(project, profile, steam, context):
records = {}
if project in {'core'}:
if context is None:
raise ValueError('Combined mod native packaging require audited external inputs')
verify_external(context)
records = prepare_profile(profile/'addons', context['source'], context['policy'], context['cache_path'])
if records != context['records']:
raise ValueError('External addon inputs changed before package launch')
elif context is not None:
raise ValueError('External links are forbidden in vanilla-only package profiles')
verify_package_profile(profile, steam, records)
return records
def validate_session(root, steam, compat, session):
if session.exists() or session.is_symlink():
raise ValueError('--session must not already exist; outputs are never reused')
if session != session.resolve() or session.parent != Path('/tmp') or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,119}', session.name):
raise ValueError('--session must be a fresh direct child of /tmp')
if any(session.is_relative_to(p) or p.is_relative_to(session) for p in (root, steam, compat)):
raise ValueError('Session must be separate from source, Steam and prefix')
if (not compat.is_relative_to(Path('/tmp')) or compat.is_relative_to(root)
or compat.is_relative_to(steam) or (compat/'pfx').is_symlink()
or (compat/'pfx/system.reg').is_symlink() or not (compat/'pfx/system.reg').is_file()):
raise ValueError('--compat must be a disposable cloned prefix under /tmp, outside source and Steam, with a real pfx')
if (compat/'.erri-workbench.lock').is_symlink():
raise ValueError('Disposable prefix lock must not be a symlink')
def copy_payload(root, session):
# Same reviewed authored schema, resource identities and dependency checks as
# source_bundle; no caches, original content, logs or vanilla bytes copied.
archive, manifest = build(root)
authored = {name: sha for name, sha in manifest['files'].items() if name.startswith('addons/')}
session.mkdir(mode=0o700) # atomic exclusive ownership; never exist_ok
source = session/'source'
with zipfile.ZipFile(io.BytesIO(archive)) as bundle:
for name, sha in authored.items():
data = bundle.read(name)
if hashlib.sha256(data).hexdigest() != sha:
raise ValueError('Validated bundle hash mismatch')
destination = source/name
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open('xb') as output:
output.write(data)
save(session/'authored-before.json', authored)
return authored
def copied_authored_snapshot(source, authored):
result = {}
for name in authored:
path = source/name
result[name] = digest(path) if path.is_file() and not path.is_symlink() and path.resolve().is_relative_to(source.resolve()) else None
# The shared inventory rejects unreviewed additions and ignores only its
# explicit generated-cache schema. Added authored scripts/resources matter.
discovered = inventory(source)
for name, sha in discovered.items():
if name not in result:
result[name] = sha
return result
def copied_authored_unchanged(source, authored):
return copied_authored_snapshot(source, authored) == authored
def command_for(steam, session, project, silent_workbench=False, exit_after_init=False):
if silent_workbench and exit_after_init:
raise ValueError("Choose only one Workbench exit mode")
if project not in PROJECTS:
raise ValueError('Unknown successor project')
command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720',
'-w', '1280', '-h', '720', '-r', '30', '--',
str(steam/'steamapps/common/Proton - Experimental/proton'), 'run',
str(steam/'steamapps/common/Arma Reforger Tools/Workbench/ArmaReforgerWorkbenchSteamDiag.exe'),
'-gproj', 'Z:'+str(session/'source/addons'/project/'addon.gproj'),
'-addonsDir', 'Z:'+str(session/'source/addons'),
'-profile', 'Z:'+str(session/'profiles'/project),
'-logconsole', '-noBackend', '-noSound', '-disableCrashReporter',
'-wbModule=ResourceManager', '-packAddon',
'-packAddonDir', 'Z:'+str(session/'packages'/project),
'-packAddonCacheDir', 'Z:'+str(session/'cache'/project)]
if silent_workbench:
command.append('-wbSilent')
elif exit_after_init:
command.append('-exitAfterInit')
return command
def child_status(path, expected):
if path.is_symlink() or not path.is_file():
return None
try:
record = json.loads(path.read_text())
except (OSError, ValueError):
return None
if not isinstance(record, dict) or record.get('command') != expected or type(record.get('exit_code')) is not int:
return None
return record['exit_code']
def log_evidence(paths):
records, failures = {}, []
for path in sorted(paths):
if path.is_symlink() or not path.is_file():
raise ValueError('Unexpected nonregular native log')
content = path.read_text(errors='replace')
report = analyze(content, path)
errors = report['engine_errors'] + report['script_or_runtime_failures'] + report['missing_end_screen']
errors += [line.strip() for line in content.splitlines()
if re.search(r"can't compile|packing (?:has )?failed|packAddon.*failed|fatal error", line, re.I)]
if path.name in {'error.log', 'crash.log'} and content.strip():
errors.append('Nonempty native '+path.name)
records[str(path)] = {'sha256': digest(path), 'bytes': path.stat().st_size, 'errors': errors}
failures.extend(errors)
return records, failures
def package_inventory(folder):
records = {}
if folder.is_symlink():
raise ValueError('Native package root is a symlink')
if not folder.is_dir():
return records
for path in folder.rglob('*'):
if path.is_symlink() or not (path.is_file() or path.is_dir()):
raise ValueError('Nonregular native package output')
if path.is_file():
records[path.relative_to(folder).as_posix()] = {'bytes': path.stat().st_size, 'sha256': digest(path)}
return records
def candidate_ok(exit_code, result, cleanup_ok, unchanged, logs, errors, packages):
return (exit_code == 0 and result in {'process-exited', 'native-child-exited'} and cleanup_ok and unchanged
and bool(logs) and not errors
and any(name.lower().endswith('.pak') and info['bytes'] > 0 for name, info in packages.items())
and all(info['bytes'] > 0 for name, info in packages.items() if name.lower().endswith('.pak')))
def quiet_streams(application_id, sink, compat):
streams = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True, timeout=5))
for stream in streams:
props = stream.get('properties', {})
owned = props.get('application.id') == application_id
pid = str(props.get('application.process.id', ''))
if not owned and pid.isdigit():
try:
owned = ('WINEPREFIX='+str(compat/'pfx')).encode() in (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
except (FileNotFoundError, PermissionError, ProcessLookupError):
pass
if owned:
for args in [['move-sink-input', str(stream['index']), sink], ['set-sink-input-mute', str(stream['index']), '1']]:
try:
subprocess.run(['pactl', *args], check=True, timeout=5, stdout=subprocess.DEVNULL)
except subprocess.CalledProcessError as operation_error:
# Short-lived startup streams can close between listing and
# routing/muting. Only a fresh, well-formed absence proves
# this race harmless; a present stream or failed read fails.
try:
current = json.loads(subprocess.check_output(['pactl', '-f', 'json', 'list', 'sink-inputs'], text=True, timeout=5))
except (OSError, subprocess.SubprocessError, ValueError) as verification_error:
raise operation_error from verification_error
if (not isinstance(current, list)
or any(not isinstance(item, dict) or type(item.get('index')) is not int or item['index'] < 0 for item in current)
or any(str(item['index']) == str(stream['index']) for item in current)):
raise
# Do not issue the remaining operation against a vanished
# index, or touch any newly listed/unrelated stream.
break
def run_project(steam, compat, session, project, timeout, authored, silent_workbench=False, exit_after_init=False, *, external=None):
if (project in {'core'}) != (external is not None):
raise ValueError('The combined addon requires audited external inputs')
command = command_for(steam, session, project, silent_workbench, exit_after_init)
proton = Path(command[command.index('--')+1])
executable = Path(command[command.index('--')+3])
profile = session/'profiles'/project
(profile/'addons').mkdir(parents=True)
(profile/'addons/ArmaReforger').symlink_to(steam/'steamapps/common/Arma Reforger/addons/data', target_is_directory=True)
for name in ('packages', 'cache', 'evidence'):
(session/name/project).mkdir(parents=True)
evidence = session/'evidence'/project
external_records = expose_external(project, profile, steam, external)
if external_records:
save(evidence/'external-addons-before.json', external_records)
separator = command.index('--')
native_command = command[separator+1:]
recorder = evidence/'native-child.py'
recorder.write_text(CHILD_RECORDER)
status_path = evidence/'native-exit.json'
environment_path = evidence/'native-prefix-environment.json'
prefix_environment = {'STEAM_COMPAT_DATA_PATH': str(compat), 'STEAM_COMPAT_CLIENT_INSTALL_PATH': str(steam),
'WINEPREFIX': str(compat/'pfx')}
save(environment_path, prefix_environment)
command = command[:separator+1] + [sys.executable, str(recorder), str(status_path), str(environment_path), *native_command]
save(evidence/'launch.json', {'command': command, 'native_command': native_command, 'authored': authored, 'started_unix': time.time(),
'executable_sha256': digest(executable), 'proton_sha256': digest(proton),
'child_recorder_sha256': digest(recorder), 'native_environment_sha256': digest(environment_path),
'external_addon_count': len(external_records),
'external_policy_sha256': external['policy_sha256'] if external else None,
'external_addon_inputs': 'external-addons-before.json' if external else None,
'silent_workbench_experiment': silent_workbench,
'exit_after_init_experiment': exit_after_init})
run_id = uuid.uuid4().hex
sink, application_id = 'erri_pack_'+run_id, 'org.escapistsrereintroduced.pack.'+run_id
env = os.environ.copy()
env.update(prefix_environment)
env.update(PULSE_SINK=sink, PULSE_PROP='application.id='+application_id)
# Prefix-idle checks must not mistake a lingering display wrapper for Wine.
# Only the recorder's direct Proton child receives these three overrides.
display_env = {key: value for key, value in env.items() if key not in prefix_environment}
child = None
module = None
exit_code = None
result, cleanup_ok, runner_error = 'timeout', True, None
cleanup_report = None
launch_log = evidence/'launch.log'
deadline = time.monotonic()+timeout
launcher_exited_at = None
try:
assert_no_game()
assert_prefix_idle(compat)
module = subprocess.check_output(['pactl', 'load-module', 'module-null-sink', 'sink_name='+sink], text=True, timeout=5).strip()
for args in [['set-sink-mute', sink, '1'], ['set-sink-volume', sink, '0%']]:
subprocess.run(['pactl', *args], check=True, timeout=5)
assert_prefix_idle(compat)
with launch_log.open('x') as output:
child = subprocess.Popen(command, cwd=executable.parent, env=display_env, stdout=output,
stderr=subprocess.STDOUT, start_new_session=True)
while time.monotonic() < deadline:
quiet_streams(application_id, sink, compat)
if time.monotonic() >= deadline:
break # audio/process observation must not extend the deadline
exit_code = child.poll()
recorded_native_exit = child_status(status_path, native_command)
if exit_code is not None or recorded_native_exit is not None:
# Gamescope exit alone cannot prove Workbench ended.
# Never qualify output completed by forcibly killing a child.
if launcher_exited_at is None:
launcher_exited_at = time.monotonic()
try:
assert_prefix_idle(compat)
if time.monotonic() >= deadline:
break
result = 'process-exited' if exit_code is not None else 'native-child-exited'
break
except ValueError:
if time.monotonic()-launcher_exited_at >= 10:
result = 'native-or-launcher-exited-prefix-active'
break
time.sleep(min(.5, max(0, deadline-time.monotonic())))
except KeyboardInterrupt:
result = 'interrupted'
except (OSError, ValueError, subprocess.SubprocessError) as error:
result, runner_error = 'runner-failed', str(error)
finally:
if child is not None:
try:
# Shared post-run adapter: normal shutdown first, then only its
# proven canonical adopted helpers, followed by strict idle proof.
# This never upgrades the completion result observed before cleanup.
cleanup_report = cleanup_owned_run(child, proton, env, compat)
cleanup_ok = isinstance(cleanup_report, dict) and cleanup_report.get('cleanup_confirmed') is True
if not cleanup_ok:
detail = 'Owned packaging cleanup unconfirmed'
if isinstance(cleanup_report, dict):
detail += ': ' + '; '.join(str(item) for item in cleanup_report.get('errors', []))
runner_error = (runner_error + '; ' if runner_error else '') + detail
except (OSError, ValueError, subprocess.SubprocessError) as error:
cleanup_ok = False
runner_error = (runner_error + '; ' if runner_error else '') + 'Owned packaging cleanup: ' + str(error)
if module is not None:
try:
subprocess.run(['pactl', 'unload-module', module], check=True, timeout=5, stdout=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError) as error:
cleanup_ok, runner_error = False, str(error)
paths = list((profile/'logs').rglob('*.log'))
if launch_log.is_file():
paths.append(launch_log)
try:
if any(not path.resolve().is_relative_to(session.resolve()) for path in paths):
raise ValueError('Native log escaped owned session')
logs, errors = log_evidence(paths)
packages = package_inventory(session/'packages'/project)
after = copied_authored_snapshot(session/'source', authored)
save(evidence/'authored-after.json', after)
unchanged = after == authored
except (OSError, ValueError) as error:
logs, packages, unchanged = {}, {}, False
errors = ['Output/evidence validation failed: '+str(error)]
profile_unchanged, external_unchanged = True, True
try:
verify_package_profile(profile, steam, external_records)
except (OSError, ValueError) as error:
profile_unchanged = False
errors.append('Private profile validation failed: '+str(error))
if external is not None:
try:
verify_external(external)
except (OSError, ValueError) as error:
external_unchanged = False
errors.append('External input validation failed: '+str(error))
if external_records:
save(evidence/'external-addons-after.json', {'records': external['last_records'], 'unchanged': external_unchanged})
save(evidence/'external-addons-verification.json', {
'unchanged': external_unchanged, 'profile_unchanged': profile_unchanged,
'policy_sha256': external['policy_sha256'], 'before': 'external-addons-before.json'})
native_logs = [name for name in logs if Path(name).name == 'console.log' and logs[name]['bytes'] > 0]
native_exit = child_status(status_path, native_command)
wrapper_ok = exit_code == 0 or (exit_code is None and result == 'native-child-exited')
candidate = wrapper_ok and profile_unchanged and external_unchanged and candidate_ok(native_exit, result, cleanup_ok, unchanged, native_logs, errors, packages)
report = {'project': project, 'result': 'native-pack-candidate' if candidate else ('validation-failed' if result in {'process-exited', 'native-child-exited'} else result),
'candidate_created': candidate, 'native_exit_code': native_exit, 'launcher_exit_code': exit_code,
'completion_observed': result, 'display_cleanup_after_native_exit': result == 'native-child-exited',
'silent_workbench_experiment': silent_workbench,
'exit_after_init_experiment': exit_after_init,
'native_status_sha256': digest(status_path) if status_path.is_file() and not status_path.is_symlink() else None,
'cleanup_confirmed': cleanup_ok, 'cleanup_report': cleanup_report,
'copied_authored_unchanged': unchanged, 'runner_error': runner_error,
'profile_addons_unchanged': profile_unchanged,
'external_addons_unchanged_after_run': external_unchanged,
'external_addon_count': len(external_records),
'external_policy_sha256': external['policy_sha256'] if external else None,
'external_addon_inputs': 'external-addons-before.json' if external else None,
'logs': logs, 'errors': errors, 'packages': packages,
'package_contents_verified': False, 'native_consumption_verified': False,
'tool_completion_semantics_verified': False, 'release_qualified': False,
'qualification': 'PENDING: inspect native contents and load packed-only addon closure; no release claim'}
save(evidence/'report.json', report)
return report
def cleaned_native_completion(report):
"""Permit another diagnostic job, never upgrade this job's failed verdict."""
cleanup = report.get('cleanup_report')
return (report.get('completion_observed') == 'native-or-launcher-exited-prefix-active'
and type(report.get('native_exit_code')) is int and report['native_exit_code'] == 0
and report.get('launcher_exit_code') in (None, 0)
and report.get('candidate_created') is False
and isinstance(cleanup, dict) and cleanup.get('cleanup_confirmed') is True
and cleanup.get('idle_verified') is True and cleanup.get('errors') == [])
def run(a, root=ROOT):
root, steam, compat = root.resolve(), a.steam.resolve(), a.compat.resolve()
session = a.session.absolute() # refuse existing symlinks before resolve
validate_session(root, steam, compat, session)
external = external_request(a.project, getattr(a, 'external_addons', None), root)
if not 30 <= a.timeout <= 1800:
raise ValueError('--timeout must be 30..1800 seconds per addon')
silent_workbench = getattr(a, 'silent_workbench', False)
exit_after_init = getattr(a, 'exit_after_init', False)
command = command_for(steam, session, a.project, silent_workbench, exit_after_init)
separator = command.index('--')
for path in (Path(command[separator+1]), Path(command[separator+3]), steam/'steamapps/common/Arma Reforger/addons/data/ArmaReforger.gproj'):
if not path.is_file():
raise ValueError('Installed runtime dependency missing: '+str(path))
if not shutil.which('gamescope') or not shutil.which('pactl'):
raise ValueError('gamescope and pactl required')
assert_no_game()
descriptor = os.open(compat/'.erri-workbench.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, 'a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise ValueError('Another native runner owns the disposable prefix')
assert_prefix_idle(compat)
authored = copy_payload(root, session)
if external is not None:
capture_external(external, session)
reports = []
diagnostic_continuations = []
order = project_order(a.project)
continue_on_failure = getattr(a, 'continue_on_failure', False)
stopped_reason = None
for project in order:
selected_external = external if project in {'core'} else None
report = run_project(steam, compat, session, project, a.timeout, authored, silent_workbench, exit_after_init, external=selected_external)
reports.append(report)
# Diagnostic continuation never grants authority to reuse an unsafe
# prefix or a changed source copy. Timeout/interruption also stop.
if external is not None:
try:
verify_external(external)
except (OSError, ValueError) as error:
stopped_reason = 'external dependency inputs changed or unverifiable: '+str(error)
if stopped_reason:
break
if report.get('profile_addons_unchanged') is False:
stopped_reason = 'private addon profile changed or unverifiable'
elif selected_external is not None and report.get('external_addons_unchanged_after_run') is not True:
stopped_reason = 'external dependency inputs changed or unverifiable'
elif not report.get('cleanup_confirmed'):
stopped_reason = 'owned cleanup unconfirmed'
elif report.get('runner_error'):
stopped_reason = 'native runner error'
elif not report.get('copied_authored_unchanged'):
stopped_reason = 'copied authored source changed or unverifiable'
elif report.get('completion_observed') not in {'process-exited', 'native-child-exited'}:
if not (continue_on_failure and cleaned_native_completion(report)):
stopped_reason = 'native completion not safely observed'
else:
# Native exited by itself, then owned cleanup removed helpers.
# Recheck idle and both authored copies before any later job.
try:
assert_no_game()
assert_prefix_idle(compat)
if inventory(root) != authored or copied_authored_snapshot(session/'source', authored) != authored:
raise ValueError('authored inputs changed after cleanup')
diagnostic_continuations.append({'after_project': project,
'native_exit_code': 0, 'idle_rechecked': True,
'authored_inputs_rechecked': True, 'failed_verdict_retained': True})
except (OSError, ValueError, subprocess.SubprocessError) as error:
stopped_reason = 'diagnostic continuation refused: '+str(error)
elif not report['candidate_created'] and not continue_on_failure:
stopped_reason = 'candidate failed; continuation not requested'
if stopped_reason:
break
if len(reports) < len(order):
try:
assert_prefix_idle(compat)
except (OSError, ValueError, subprocess.SubprocessError) as error:
stopped_reason = 'prefix not verifiably idle before next addon: '+str(error)
break
try:
original_after = inventory(root)
save(session/'repository-after.json', original_after)
original_unchanged = original_after == authored
except (OSError, ValueError):
original_unchanged = False
external_unchanged = True
if external is not None:
try:
verify_external(external)
except (OSError, ValueError) as error:
external_unchanged = False
if stopped_reason is None:
stopped_reason = 'external dependency final verification failed: '+str(error)
save(session/'external-addons-after.json', {'records': external['last_records'], 'unchanged': external_unchanged})
summary = {'kind': 'UNQUALIFIED NATIVE PACKAGE ATTEMPT', 'session': str(session),
'project_order': order, 'projects': reports,
'continue_on_failure': continue_on_failure, 'diagnostic_continuations': diagnostic_continuations, 'stopped_reason': stopped_reason,
'skipped_projects': order[len(reports):],
'source_repository_unchanged': original_unchanged,
'external_addons_unchanged_after_run': external_unchanged,
'external_addon_count': len(external['records']) if external else 0,
'external_addon_inputs': 'external-addons-before.json' if external else None,
'external_policy_sha256': external['policy_sha256'] if external else None,
'release_qualified': False, 'published': False,
'qualification': 'PENDING native contents inspection and packed-only consumption'}
summary['all_candidates_created'] = original_unchanged and external_unchanged and stopped_reason is None and len(reports) == len(order) and all(r['candidate_created'] for r in reports)
save(session/'package-report.json', summary)
print(json.dumps(summary, indent=2))
return 0 if summary['all_candidates_created'] else 1
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--project', choices=PROJECTS, default='core', help='Build the single combined addon')
parser.add_argument('--session', type=Path, required=True, help='New direct child of /tmp; never overwritten')
parser.add_argument('--compat', type=Path, required=True, help='Idle disposable cloned Tools prefix under /tmp')
parser.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
parser.add_argument('--timeout', type=int, default=300, help='30..1800 seconds per addon')
parser.add_argument('--external-addons', type=Path, help='Required for the combined mod: installed audited RHS/WCS/BMP root; only allowlisted links enter private profiles')
lifecycle = parser.add_mutually_exclusive_group()
lifecycle.add_argument('--silent-workbench', action='store_true', help='Opt-in documented -wbSilent experiment; packing compatibility still requires native evidence')
lifecycle.add_argument('--exit-after-init', action='store_true', help='Opt-in documented -exitAfterInit experiment; natural exit and all native error gates remain mandatory')
parser.add_argument('--continue-on-failure', action='store_true', help='Diagnostic only: continue completed, safely cleaned addon attempts after validation failures; any failure keeps overall exit nonzero and unqualified')
args = parser.parse_args(argv)
try:
return run(args)
except (ValueError, OSError, subprocess.SubprocessError) as error:
print(json.dumps({'result': 'refused-or-failed', 'error': str(error), 'release_qualified': False}))
return 1
if __name__ == '__main__':
raise SystemExit(main())