Files
TerminalBasic/.gitea/scripts/distribution.py
Chili Palmer 32d3838002
Some checks failed
Vierziel-Build und Pflichtabnahme / checks (push) Has been cancelled
Vierziel-Build und Pflichtabnahme / build (ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b, aarch64-apple-darwin) (push) Has been cancelled
Vierziel-Build und Pflichtabnahme / build (ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b, aarch64-unknown-linux-gnu) (push) Has been cancelled
Vierziel-Build und Pflichtabnahme / build (ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b, x86_64-unknown-linux-gnu) (push) Has been cancelled
Vierziel-Build und Pflichtabnahme / build (messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0, x86_64-pc-windows-msvc) (push) Has been cancelled
Vierziel-Build und Pflichtabnahme / stage (push) Has been cancelled
Phase 6: Gitea-Cross-Builds, Paketprüfung und gesperrten Releasepfad implementieren
2026-09-07 21:50:05 +02:00

250 lines
14 KiB
Python

#!/usr/bin/env python3
"""Vierzielpakete bauen, entpackt prüfen und vollständige Freigaben validieren."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import tomllib
ROOT = Path(__file__).resolve().parents[2]
TARGETS = {
'x86_64-pc-windows-msvc': ('windows-amd64', '.7z'),
'aarch64-apple-darwin': ('macos-arm64', '.tar.gz'),
'x86_64-unknown-linux-gnu': ('linux-amd64', '.tar.gz'),
'aarch64-unknown-linux-gnu': ('linux-arm64', '.tar.gz'),
}
HOSTS = {('Windows', 'AMD64'): list(TARGETS)[0], ('Darwin', 'arm64'): list(TARGETS)[1],
('Linux', 'x86_64'): list(TARGETS)[2], ('Linux', 'aarch64'): list(TARGETS)[3]}
TBL_SHA = '5d25c2f8c95ae535e55a6c84c1ddd0d964358863f4f3c780c50ac4a2eb3594df'
def require(condition, message):
if not condition:
raise ValueError(message)
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def run(command, **kwargs):
print('+', ' '.join(map(str, command)), flush=True)
return subprocess.run(list(map(str, command)), check=True, timeout=3600, **kwargs)
def name(version, target):
require(re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?', version), 'Ungültige Paketversion')
label, extension = TARGETS[target]
return f'terminalbasic-{version}-{label}{extension}'
def sevenzip():
tool = shutil.which('7zz') or shutil.which('7z')
require(tool, '7-Zip fehlt; Windows erfordert echte .7z-Archive')
return tool
def programs(target):
suffix = '.exe' if target == list(TARGETS)[0] else ''
return ['tb' + suffix, 'tbc' + suffix, f'runtimes/{target}/tbrt' + suffix]
def files(target):
return set(programs(target) + [programs(target)[2] + '.meta', 'LICENSE', 'INSTALL.txt', 'manifest.json'])
def unpack(archive, dest, target):
"""Nur bekannte reguläre Dateien; keine Symlinks, Unterverzeichnisse oder Traversierung."""
if archive.name.endswith('.7z'):
listing = subprocess.check_output([sevenzip(), 'l', '-slt', '--', str(archive)], text=True)
members = listing.split('----------\n', 1)[-1].strip().split('\n\n')
found = set()
for member in members:
fields = dict(line.split(' = ', 1) for line in member.splitlines() if ' = ' in line)
path = fields.get('Path')
require(path in files(target) and path not in found, f'Unerlaubter Archiveintrag: {path}')
require(fields.get('Folder', '-') == '-' and not fields.get('Symbolic Link') and
not fields.get('Hard Link') and not fields.get('Attributes', '').startswith('D'), 'Archivlink/-verzeichnis')
found.add(path)
require(found == files(target), 'Unvollständiges 7z-Archiv')
run([sevenzip(), 'x', '-y', '-o' + str(dest), '--', archive], stdout=subprocess.DEVNULL)
else:
with tarfile.open(archive, 'r:gz') as tar:
members = tar.getmembers()
require(len(members) == len(files(target)) and {m.name for m in members} == files(target), 'Falscher Archivinhalt')
require(all(m.isfile() for m in members), 'Nur reguläre Dateien erlaubt')
for member in members:
data = tar.extractfile(member).read()
path = dest / member.name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
path.chmod(member.mode & 0o777)
require({p.relative_to(dest).as_posix() for p in dest.rglob('*') if p.is_file()} == files(target), 'Falscher entpackter Inhalt')
require(all(not p.is_symlink() and (p.is_file() or p.is_dir()) for p in dest.rglob('*')), 'Unerlaubter Dateityp')
def verify(archive, target, inspector=None):
with tempfile.TemporaryDirectory(prefix='tb-package-') as temp:
dest = Path(temp)
unpack(archive, dest, target)
manifest = json.loads((dest / 'manifest.json').read_text())
require(manifest['target'] == target and archive.name == name(manifest['version'], target), 'Falsches Paketziel/-version')
require(set(manifest['files']) == files(target) - {'manifest.json'}, 'Falsches Dateimanifest')
for path, sha in manifest['files'].items():
require(digest(dest / path) == sha, f'Beschädigte Datei: {path}')
for binary in programs(target):
if inspector:
run([inspector, '--inspect', dest / binary, target], stdout=subprocess.DEVNULL)
if target != list(TARGETS)[0]:
require((dest / binary).stat().st_mode & 0o111 == 0o111, 'Executable-Rechte fehlen')
metadata = (dest / (programs(target)[2] + '.meta')).read_text()
require(f'target={target}\n' in metadata and f'package={manifest["version"]}\n' in metadata, 'Vorlagenversion/-ziel falsch')
return manifest
def pack(binary_dir, target, output, inspector):
version = tomllib.loads((ROOT / 'Cargo.toml').read_text())['workspace']['package']['version']
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip()
output.mkdir(parents=True, exist_ok=True)
archive = output / name(version, target)
require(not archive.exists(), f'Paket existiert bereits: {archive}')
with tempfile.TemporaryDirectory(prefix='tb-pack-') as temp:
stage = Path(temp)
for binary in programs(target):
run([inspector, '--inspect', binary_dir / Path(binary).name, target], stdout=subprocess.DEVNULL)
(stage / binary).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(binary_dir / Path(binary).name, stage / binary)
(stage / binary).chmod(0o755)
run([inspector, stage / programs(target)[2], target])
shutil.copyfile(ROOT / 'LICENSE', stage / 'LICENSE')
shutil.copyfile(ROOT / '.gitea/INSTALL.txt', stage / 'INSTALL.txt')
manifest = dict(version=version, revision=revision, target=target,
buildhost=platform.platform(), rustc=subprocess.check_output(['rustc', '--version'], text=True).strip(),
build_image=os.environ.get('TB_BUILD_IMAGE', 'local'),
cross_toolchain=json.loads(os.environ.get('TB_BUILD_METADATA', '{}')),
source_dirty=bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT)),
files={p.relative_to(stage).as_posix(): digest(p) for p in stage.rglob('*') if p.is_file()})
(stage / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
if target == list(TARGETS)[0]:
run([sevenzip(), 'a', '-t7z', archive, *sorted(files(target))], cwd=stage, stdout=subprocess.DEVNULL)
else:
with tarfile.open(archive, 'w:gz') as tar:
for filename in sorted(files(target)):
tar.add(stage / filename, arcname=filename)
verify(archive, target, inspector)
(output / (archive.name + '.sha256')).write_text(f'{digest(archive)} {archive.name}\n')
print(archive)
def target_check(archive, target, output, windows_console_test=None):
require(HOSTS.get((platform.system(), platform.machine())) == target, 'Echte Zielausführung erfordert passendes Hostsystem')
require(not output.exists(), 'Nachweis existiert bereits')
manifest = verify(archive, target)
report = dict(target=target, revision=manifest['revision'], package=archive.name,
package_sha256=digest(archive), tbl_sha256=TBL_SHA, host=platform.platform(),
automated_passed=False, manual_terminal_passed=False, checks={})
output.mkdir(parents=True)
try:
with tempfile.TemporaryDirectory(prefix='tb-unpacked-') as temp:
dest = Path(temp)
unpack(archive, dest, target)
env = dict(os.environ, PATH='')
run([dest / programs(target)[0], '--help'], cwd=dest, env=env, stdout=subprocess.DEVNULL)
source = dest / 'hello.bas'; source.write_text('PRINT 42\nEND\n')
executable = dest / ('hello.exe' if os.name == 'nt' else 'hello')
run([dest / programs(target)[1], 'build', source, '--exe', '--target', target, '-o', executable], cwd=dest, env=env)
source.unlink()
actual = subprocess.check_output([str(executable)], cwd=dest, env=env, timeout=30)
require(actual.replace(b'\r\n', b'\n') == b' 42 \n', 'Standard-Vorlagenpfad funktioniert nicht')
report['checks']['default-template'] = 'passed'
commands = {
'native': [sys.executable, ROOT / 'tests/support/native-abnahme.py', '--bin-dir', dest, '--target', target],
'library': [sys.executable, ROOT / 'tests/support/library-abnahme.py', '--bin-dir', dest],
}
if os.name == 'posix':
commands['ide-pty'] = [sys.executable, ROOT / 'tests/support/ide-execution-pty.py', '--binary', dest / 'tb']
if os.name == 'nt':
require(windows_console_test and windows_console_test.is_file(), 'Windows-Konsolentest fehlt')
report['windows_console_test_sha256'] = digest(windows_console_test)
commands['windows-console'] = [windows_console_test, '--exact',
'terminal::tests::windows_console_restores_modes', '--ignored', '--nocapture']
for check, command in commands.items():
with (output / (check + '.log')).open('wb') as log:
run(command, cwd=dest, stdout=log, stderr=subprocess.STDOUT)
report['checks'][check] = 'passed'
report['automated_passed'] = True
finally:
require(digest(archive) == report['package_sha256'], 'Paket während Zielprüfung verändert')
(output / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
def gate(directory, evidence, revision, version):
"""Fail closed: vier exakte Pakete und unabhängige, passende Zielnachweise."""
require(re.fullmatch('[0-9a-f]{40}', revision), 'Ungültige Revision')
require(digest(ROOT / 'tests/libraries/portable.tbl') == TBL_SHA, 'TBL-Probe verändert')
assets = []
for target in TARGETS:
archive = directory / name(version, target)
require(archive.is_file(), f'Pflichtpaket fehlt: {archive.name}')
sha = digest(archive)
checksum = archive.with_name(archive.name + '.sha256')
require(checksum.read_text() == f'{sha} {archive.name}\n', 'Paketprüfsumme falsch')
manifest = verify(archive, target, ROOT / 'target/release/tb-template')
require(manifest['revision'] == revision and not manifest['source_dirty'], 'Paket aus falschem/uncommittetem Stand')
proof = json.loads((evidence / target / 'report.json').read_text())
require(proof.get('target') == target and proof.get('revision') == revision and proof.get('package') == archive.name and
proof.get('package_sha256') == sha and proof.get('tbl_sha256') == TBL_SHA, 'Zielnachweis passt nicht zum Paket')
required = {'native', 'library', 'default-template', 'windows-console' if target == list(TARGETS)[0] else 'ide-pty'}
require(proof.get('automated_passed') is True and all(proof.get('checks', {}).get(k) == 'passed' for k in required), 'Automatisierte Zielprüfung fehlt')
manual = json.loads((evidence / target / 'manual.json').read_text())
require(manual.get('revision') == revision and manual.get('package_sha256') == sha and
manual.get('target') == target and manual.get('passed') is True and
manual.get('tester') and manual.get('terminal_versions') and manual.get('evidence'), 'Manueller Terminalnachweis fehlt')
terminals = {'windows-terminal'} if target == list(TARGETS)[0] else {'terminal.app'} if target == list(TARGETS)[1] else {'xterm', 'vte'}
require(isinstance(manual['terminal_versions'], dict) and
all(manual['terminal_versions'].get(t) for t in terminals), 'Pflichtemulator/Version fehlt')
require(all(manual.get('cases', {}).get(k) is True for k in
('f1_f12', 'modifiers', 'mouse', 'unicode_colors', 'resize', 'input_break', 'shell')), 'Manuelle Pflichtfolge unvollständig')
for reference in manual['evidence']:
relative = Path(reference)
require(not relative.is_absolute() and '..' not in relative.parts, 'Ungültiger manueller Nachweispfad')
path = evidence / target / relative
require(path.is_file() and path.stat().st_size > 0, 'Manueller Beleg fehlt')
assets.extend([archive, checksum])
acceptance = json.loads((evidence / 'acceptance.json').read_text())
require(acceptance.get('revision') == revision and acceptance.get('passed') is True and
all(acceptance.get(k) for k in ('foreign', 'inventory', 'workspace', 'benchmarks', 'native_ide', 'run_url', 'buildhost')), 'Pflichtabnahme/Benchmarks fehlen')
return assets
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True)
p = sub.add_parser('pack'); p.add_argument('--bin-dir', type=Path, required=True); p.add_argument('--output', type=Path, required=True)
p.add_argument('--inspector', type=Path, required=True); p.add_argument('--target', choices=TARGETS, required=True)
for command in ('verify', 'target-check'):
p = sub.add_parser(command); p.add_argument('--archive', type=Path, required=True); p.add_argument('--target', choices=TARGETS, required=True)
if command == 'verify': p.add_argument('--inspector', type=Path)
else:
p.add_argument('--output', type=Path, required=True)
p.add_argument('--windows-console-test', type=Path)
p = sub.add_parser('gate'); p.add_argument('--packages', type=Path, required=True); p.add_argument('--evidence', type=Path, required=True)
p.add_argument('--revision', required=True); p.add_argument('--version', required=True)
args = parser.parse_args()
if args.command == 'pack': pack(args.bin_dir.resolve(), args.target, args.output.resolve(), args.inspector.resolve())
elif args.command == 'verify': verify(args.archive.resolve(), args.target, args.inspector.resolve() if args.inspector else None)
elif args.command == 'target-check': target_check(args.archive.resolve(), args.target, args.output.resolve(), args.windows_console_test.resolve() if args.windows_console_test else None)
else: gate(args.packages, args.evidence, args.revision, args.version)
if __name__ == '__main__':
main()