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
250 lines
14 KiB
Python
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()
|