Phase 6: Gitea-Cross-Builds, Paketprüfung und gesperrten Releasepfad implementieren
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
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
This commit is contained in:
37
.gitea/scripts/acceptance.py
Normal file
37
.gitea/scripts/acceptance.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pflichtabnahme mit fixiertem Fremdbestand und zugeordneter Runner-Messung."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import distribution as dist
|
||||
|
||||
output = Path('acceptance')
|
||||
output.mkdir()
|
||||
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
|
||||
report = dict(revision=revision, passed=False, buildhost=platform.platform(),
|
||||
run_url=os.environ.get('TB_RUN_URL', ''), foreign=False, inventory=False,
|
||||
workspace=False, benchmarks=False, native_ide=False)
|
||||
try:
|
||||
repo = Path(os.environ['TB_VBDOS_REPO'])
|
||||
with (output / 'acceptance.log').open('wb') as log:
|
||||
dist.run([sys.executable, dist.ROOT / 'tests/support/release-abnahme.py', '--vbdos-repo', repo],
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
with tempfile.TemporaryDirectory(prefix='tb-ci-runtime-') as temporary:
|
||||
runtime = Path(temporary) / 'runtime'
|
||||
with (output / 'native-ide.log').open('wb') as log:
|
||||
dist.run([sys.executable, dist.ROOT / 'tests/support/build-runtime.py',
|
||||
'--target', dist.HOSTS[(platform.system(), platform.machine())],
|
||||
'--output', runtime / dist.HOSTS[(platform.system(), platform.machine())]],
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
dist.run(['cargo', 'test', '--locked', '-p', 'tb-cli', '--bin', 'tbc',
|
||||
'ide_acceptance::real_native_ide_exports_and_source_free_cli_library_consumer',
|
||||
'--', '--exact', '--ignored', '--nocapture'],
|
||||
env=dict(os.environ, TB_IDE_RUNTIME_DIR=str(runtime)), stdout=log, stderr=subprocess.STDOUT)
|
||||
report.update(passed=True, native_ide=True, foreign=True, inventory=True, workspace=True, benchmarks=True)
|
||||
finally:
|
||||
report['log_sha256'] = dist.digest(output / 'acceptance.log') if (output / 'acceptance.log').exists() else None
|
||||
(output / 'acceptance.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
13
.gitea/scripts/bootstrap.sh
Normal file
13
.gitea/scripts/bootstrap.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
[ "$(uname -sm)" = "Linux aarch64" ] || { echo 'Linux-arm64-Buildhost erforderlich' >&2; exit 1; }
|
||||
rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt
|
||||
# Beide per Digest fixierten Cross-Images enthalten Python, Curl, Git und SDK/Clang.
|
||||
# Separater fester 7-Zip-Download, kein stiller Ersatz durch ZIP oder tar.
|
||||
curl --fail --location --silent --show-error https://www.7-zip.org/a/7z2501-linux-arm64.tar.xz -o /tmp/tb-7zip.tar.xz
|
||||
printf '%s %s\n' 39c5140f02ce4436599303c59a149f654cb1bbc47cdc105a942120d747ae040d /tmp/tb-7zip.tar.xz | sha256sum -c -
|
||||
mkdir -p /tmp/tb-7zip
|
||||
tar -xJf /tmp/tb-7zip.tar.xz -C /tmp/tb-7zip
|
||||
install /tmp/tb-7zip/7zz /usr/local/bin/7zz
|
||||
rustc --version
|
||||
7zz | head -4
|
||||
62
.gitea/scripts/build.py
Normal file
62
.gitea/scripts/build.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ein Cross-Ziel auf dem Linux-arm64-Runner bauen und mit Hostwerkzeug paketieren."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import distribution as dist
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--target', choices=dist.TARGETS, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
dist.require(platform.system() == 'Linux' and platform.machine() == 'aarch64', 'Buildhost muss Linux arm64 sein')
|
||||
target = args.target
|
||||
env = dict(os.environ, CARGO_BUILD_JOBS='2', MACOSX_DEPLOYMENT_TARGET='11.0',
|
||||
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS='-C target-feature=+crt-static',
|
||||
XWIN_VERSION='17', XWIN_SDK_VERSION='10.0.26100', XWIN_CRT_VERSION='14.44.17.14')
|
||||
if target.endswith('msvc'):
|
||||
builder = ['cargo', 'xwin', 'build']
|
||||
build_target = target
|
||||
else:
|
||||
builder = ['cargo', 'zigbuild']
|
||||
build_target = target + ('.2.28' if target.endswith('linux-gnu') else '')
|
||||
dist.run(['rustup', 'target', 'add', target])
|
||||
dist.run(builder + ['--locked', '--release', '--target', build_target,
|
||||
'-p', 'tb-ide', '-p', 'tb-cli', '-p', 'tb-runner', '--bins'], cwd=dist.ROOT, env=env)
|
||||
dist.run(['cargo', 'build', '--locked', '--release', '-p', 'tb-export', '--bin', 'tb-template'], cwd=dist.ROOT, env=env)
|
||||
cargo_target = Path(os.environ.get('CARGO_TARGET_DIR', dist.ROOT / 'target')).resolve()
|
||||
metadata = dict(builder=builder[1], build_target=build_target, macos_deployment='11.0')
|
||||
if target.endswith('msvc'):
|
||||
# Exakte Header/Library-Bytes pinnen; Microsoft-Kanäle können ihre
|
||||
# Pakete auch unter derselben SDK-/CRT-Bezeichnung aktualisieren.
|
||||
cache = Path.home() / '.cache/cargo-xwin/xwin'
|
||||
checksum = hashlib.sha256()
|
||||
for path in sorted(cache.rglob('*')):
|
||||
if path.relative_to(cache).parts[0] not in ('crt', 'sdk'):
|
||||
continue
|
||||
if path.is_file() or path.is_symlink():
|
||||
checksum.update(path.relative_to(cache).as_posix().encode() + b'\0')
|
||||
checksum.update((str(path.readlink()).encode() if path.is_symlink() else
|
||||
hashlib.sha256(path.read_bytes()).digest()) + b'\0')
|
||||
dist.require(checksum.hexdigest() == '622fb2c2e3560514bfd59b87114c2aea72c15514614e95d01ddae7def96de047',
|
||||
'SDK-/CRT-Inhalt weicht vom geprüften Build ab; Pin bewusst aktualisieren')
|
||||
metadata.update(sdk='10.0.26100', crt='14.44.17.14', sdk_tree_sha256=checksum.hexdigest(), crt_static=True)
|
||||
os.environ['TB_BUILD_METADATA'] = json.dumps(metadata)
|
||||
dist.pack(cargo_target / target / 'release', target, args.output.resolve(), cargo_target / 'release/tb-template')
|
||||
# Abhängigkeiten und Build-Baselines offen ausweisen; das sind keine gemessenen Mindest-OS-Versionen.
|
||||
with (args.output / (dist.TARGETS[target][0] + '-build.txt')).open('w') as log:
|
||||
for command in (['uname', '-a'], ['rustc', '-vV'], ['cargo', '--version']):
|
||||
subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True)
|
||||
log.write(f'image={os.environ.get("TB_BUILD_IMAGE", "local")}\n'
|
||||
f'build_target={build_target}\nMACOSX_DEPLOYMENT_TARGET=11.0\n'
|
||||
'XWIN_VERSION=17\nXWIN_SDK_VERSION=10.0.26100\nXWIN_CRT_VERSION=14.44.17.14\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
249
.gitea/scripts/distribution.py
Normal file
249
.gitea/scripts/distribution.py
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/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()
|
||||
37
.gitea/scripts/publish.py
Normal file
37
.gitea/scripts/publish.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Nur einen bestehenden Tag und Nachweise aus einer Origin-Revision verwenden."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import distribution as dist
|
||||
import release
|
||||
|
||||
tag, evidence_revision = os.environ['TB_TAG'], os.environ['TB_EVIDENCE_REVISION']
|
||||
dist.require(re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?', tag), 'Ungültiger Tag')
|
||||
dist.require(re.fullmatch('[0-9a-f]{40}', evidence_revision), 'Ungültige Nachweisrevision')
|
||||
revision = subprocess.check_output(['git', 'rev-parse', '--verify', tag + '^{commit}'], text=True).strip()
|
||||
api = release.Gitea()
|
||||
info = api.request('GET', '/git/commits/' + evidence_revision)
|
||||
dist.require(info['sha'] == evidence_revision, 'Nachweise nicht am Origin')
|
||||
# Checkout enthält alle Origin-Branches. Ein nicht vorhandener Commit scheitert hier.
|
||||
base = f'release-evidence/{tag}'
|
||||
names = subprocess.check_output(['git', 'ls-tree', '-r', '--name-only', evidence_revision, base], text=True).splitlines()
|
||||
with tempfile.TemporaryDirectory(prefix='tb-release-') as tmp:
|
||||
evidence = Path(tmp) / 'evidence'; evidence.mkdir()
|
||||
for name in names:
|
||||
relative = Path(name).relative_to(base)
|
||||
dist.require('..' not in relative.parts and not relative.is_absolute(), 'Ungültiger Nachweispfad')
|
||||
path = evidence / relative; path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(subprocess.check_output(['git', 'show', evidence_revision + ':' + name]))
|
||||
packages = Path(tmp) / 'packages'; packages.mkdir()
|
||||
draft = api.request('GET', '/releases/tags/' + tag)
|
||||
wanted = release.expected(tag[1:])
|
||||
dist.require(draft['draft'] and len(draft.get('assets', [])) == len(wanted) and
|
||||
{a['name'] for a in draft['assets']} == set(wanted), 'Unvollständiger Entwurf')
|
||||
for asset in draft['assets']:
|
||||
(packages / asset['name']).write_bytes(api.download(asset))
|
||||
# Grundabnahme kommt aus dem ursprünglichen Taglauf, nicht aus manuell ersetzten Angaben.
|
||||
(evidence / 'acceptance.json').write_bytes((packages / 'acceptance.json').read_bytes())
|
||||
release.publish(api, packages, evidence, tag, revision)
|
||||
130
.gitea/scripts/release.py
Normal file
130
.gitea/scripts/release.py
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gitea-Entwurf befüllen; erst nach exakten Paket-/Zielnachweisen freigeben."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.request import Request, HTTPRedirectHandler, build_opener
|
||||
import distribution as dist
|
||||
|
||||
|
||||
class SafeRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if redirected and urlparse(newurl).netloc != urlparse(req.full_url).netloc:
|
||||
redirected.remove_header('Authorization')
|
||||
return redirected
|
||||
|
||||
|
||||
class Gitea:
|
||||
def __init__(self):
|
||||
self.base = os.environ['GITEA_SERVER_URL'].rstrip('/')
|
||||
dist.require(self.base == 'https://git.rfc1437.de', 'Release nur am bestehenden Origin')
|
||||
dist.require(os.environ['GITEA_REPOSITORY'] == 'hugo/TerminalBasic', 'Falsches Release-Repository')
|
||||
self.api = self.base + '/api/v1/repos/hugo/TerminalBasic'
|
||||
self.token = os.environ['TB_RELEASE_TOKEN']
|
||||
self.open = build_opener(SafeRedirect()).open
|
||||
|
||||
def request(self, method, path, data=None):
|
||||
raw = isinstance(data, bytes)
|
||||
headers = {'Authorization': 'token ' + self.token,
|
||||
'Content-Type': 'application/octet-stream' if raw else 'application/json'}
|
||||
payload = data if raw or data is None else json.dumps(data).encode()
|
||||
with self.open(Request(self.api + path, data=payload, headers=headers, method=method), timeout=120) as response:
|
||||
return json.load(response)
|
||||
|
||||
def download(self, asset):
|
||||
url = asset['browser_download_url']
|
||||
dist.require(urlparse(url).netloc == urlparse(self.base).netloc, 'Fremder Assetserver')
|
||||
with self.open(Request(url, headers={'Authorization': 'token ' + self.token}), timeout=120) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def expected(version):
|
||||
return [n for t in dist.TARGETS for n in (dist.name(version, t), dist.name(version, t) + '.sha256')] + ['acceptance.json']
|
||||
|
||||
|
||||
def stage(api, directory, tag, revision):
|
||||
version = tag.removeprefix('v')
|
||||
dist.require(tag == 'v' + version and re.fullmatch('[0-9a-f]{40}', revision), 'Tag/Revision ungültig')
|
||||
dist.require(api.request('GET', '/tags/' + quote(tag, safe=''))['commit']['sha'] == revision, 'Tag zeigt auf andere Revision')
|
||||
names = expected(version)
|
||||
for target in dist.TARGETS:
|
||||
path = directory / dist.name(version, target)
|
||||
manifest = dist.verify(path, target, dist.ROOT / 'target/release/tb-template')
|
||||
dist.require(manifest['revision'] == revision and not manifest['source_dirty'], 'Paketrevision falsch/uncommitted')
|
||||
dist.require(path.with_name(path.name + '.sha256').read_text() == f'{dist.digest(path)} {path.name}\n', 'Paketprüfsumme falsch')
|
||||
acceptance = json.loads((directory / 'acceptance.json').read_text())
|
||||
dist.require(acceptance.get('revision') == revision and acceptance.get('passed') is True, 'Grundabnahme fehlt')
|
||||
try:
|
||||
release = api.request('GET', '/releases/tags/' + quote(tag, safe=''))
|
||||
except HTTPError as error:
|
||||
if error.code != 404:
|
||||
raise
|
||||
release = api.request('POST', '/releases', dict(tag_name=tag, target_commitish=revision,
|
||||
name='Terminal Basic ' + version, draft=True,
|
||||
body='Vierzielpakete im Entwurf. Zielnachweise fehlen noch; keine Freigabe.'))
|
||||
dist.require(release['draft'], 'Bereits veröffentlichten Release nicht verändern')
|
||||
# Wiederholungen dürfen identische Uploads ergänzen, keine vorhandenen Assets ersetzen.
|
||||
existing = {a['name']: a for a in release.get('assets', [])}
|
||||
dist.require(set(existing) <= set(names), 'Unerwartete Assets im Entwurf')
|
||||
for name in names:
|
||||
data = (directory / name).read_bytes()
|
||||
if name in existing:
|
||||
dist.require(api.download(existing[name]) == data, f'Abweichendes vorhandenes Asset: {name}')
|
||||
else:
|
||||
api.request('POST', f'/releases/{release["id"]}/assets?name=' + quote(name), data)
|
||||
verify_uploads(api, release['id'], directory, names)
|
||||
return release['id']
|
||||
|
||||
|
||||
def verify_uploads(api, release_id, directory, names):
|
||||
release = api.request('GET', f'/releases/{release_id}')
|
||||
assets = release.get('assets', [])
|
||||
dist.require(len(assets) == len(names) and {a['name'] for a in assets} == set(names), 'Unvollständiger Asset-Upload')
|
||||
for asset in assets:
|
||||
dist.require(hashlib.sha256(api.download(asset)).hexdigest() == dist.digest(directory / asset['name']), 'Uploadprüfsumme falsch')
|
||||
return release
|
||||
|
||||
|
||||
def publish(api, directory, evidence, tag, revision):
|
||||
version = tag.removeprefix('v')
|
||||
dist.require(tag == 'v' + version, 'Versionstag erforderlich')
|
||||
dist.require(api.request('GET', '/tags/' + quote(tag, safe=''))['commit']['sha'] == revision, 'Tag zeigt auf andere Revision')
|
||||
dist.gate(directory, evidence, revision, version)
|
||||
release = api.request('GET', '/releases/tags/' + quote(tag, safe=''))
|
||||
verify_uploads(api, release['id'], directory, expected(version))
|
||||
dist.require(release['draft'], 'Bereits freigegebenen Release nicht verändern')
|
||||
api.request('PATCH', f'/releases/{release["id"]}', dict(draft=False,
|
||||
body=f'Geprüfte Vierzielpakete für Revision {revision}. Zugehörige Zielnachweise: {os.environ.get("TB_EVIDENCE_REVISION", "lokaler Nachweissatz")}.'))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=['stage', 'download', 'publish'])
|
||||
parser.add_argument('--packages', type=Path, required=True)
|
||||
parser.add_argument('--tag', required=True); parser.add_argument('--revision', required=True)
|
||||
parser.add_argument('--evidence', type=Path)
|
||||
args = parser.parse_args()
|
||||
api = Gitea()
|
||||
if args.command == 'stage': stage(api, args.packages, args.tag, args.revision)
|
||||
elif args.command == 'publish':
|
||||
dist.require(args.evidence, '--evidence erforderlich')
|
||||
publish(api, args.packages, args.evidence, args.tag, args.revision)
|
||||
else:
|
||||
args.packages.mkdir(parents=True) # Nie lokale Kandidaten überschreiben.
|
||||
release = api.request('GET', '/releases/tags/' + quote(args.tag, safe=''))
|
||||
names = expected(args.tag.removeprefix('v'))
|
||||
dist.require(release['draft'], 'Nur bestehende Entwürfe freigeben')
|
||||
assets = release.get('assets', [])
|
||||
dist.require(len(assets) == len(names) and {a['name'] for a in assets} == set(names), 'Pflichtassets fehlen')
|
||||
for asset in assets:
|
||||
(args.packages / asset['name']).write_bytes(api.download(asset))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
122
.gitea/scripts/test_distribution.py
Normal file
122
.gitea/scripts/test_distribution.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import distribution as d
|
||||
import release as r
|
||||
|
||||
|
||||
class DistributionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.packages = self.root / 'packages'; self.packages.mkdir()
|
||||
self.evidence = self.root / 'evidence'; self.evidence.mkdir()
|
||||
self.revision = 'a' * 40
|
||||
self.version = '0.1.0'
|
||||
self.manifests = {}
|
||||
for target in d.TARGETS:
|
||||
path = self.packages / d.name(self.version, target)
|
||||
path.write_bytes(target.encode())
|
||||
path.with_name(path.name + '.sha256').write_text(f'{d.digest(path)} {path.name}\n')
|
||||
self.manifests[target] = dict(revision=self.revision, version=self.version, source_dirty=False)
|
||||
proof = self.evidence / target; proof.mkdir()
|
||||
report = dict(target=target, revision=self.revision, package=path.name,
|
||||
package_sha256=d.digest(path), tbl_sha256=d.TBL_SHA, automated_passed=True,
|
||||
checks={k:'passed' for k in ('native','library','ide-pty','windows-console','default-template')})
|
||||
(proof / 'report.json').write_text(json.dumps(report))
|
||||
(proof / 'manual.json').write_text(json.dumps(dict(target=target, revision=self.revision,
|
||||
package_sha256=d.digest(path), passed=True, tester='Prüfer',
|
||||
terminal_versions={k:'Version 1' for k in ('windows-terminal','terminal.app','xterm','vte')},
|
||||
cases={k:True for k in ('f1_f12','modifiers','mouse','unicode_colors','resize','input_break','shell')}, evidence=['terminal.txt'])))
|
||||
(proof / 'terminal.txt').write_text('Bedienprotokoll')
|
||||
acceptance = dict(revision=self.revision, passed=True, foreign=True, inventory=True,
|
||||
workspace=True, benchmarks=True, native_ide=True, run_url='https://git.rfc1437.de/run/1', buildhost='Linux arm64')
|
||||
for directory in (self.packages, self.evidence):
|
||||
(directory / 'acceptance.json').write_text(json.dumps(acceptance))
|
||||
self.mock_verify = patch.object(d, 'verify', side_effect=lambda archive, target, *args: self.manifests[target])
|
||||
self.mock_verify.start(); self.addCleanup(self.mock_verify.stop)
|
||||
|
||||
def gate(self):
|
||||
return d.gate(self.packages, self.evidence, self.revision, self.version)
|
||||
|
||||
def test_complete_gate_and_missing_or_wrong_evidence(self):
|
||||
self.assertEqual(len(self.gate()), 8)
|
||||
target = list(d.TARGETS)[0]
|
||||
path = self.evidence / target / 'report.json'
|
||||
valid = json.loads(path.read_text())
|
||||
for key, value in [('revision','b'*40), ('target',list(d.TARGETS)[1]), ('package_sha256','0'*64),
|
||||
('tbl_sha256','0'*64), ('automated_passed',False), ('checks',{'native':'passed'})]:
|
||||
broken = dict(valid, **{key:value}); path.write_text(json.dumps(broken))
|
||||
with self.assertRaises(ValueError, msg=key): self.gate()
|
||||
path.write_text(json.dumps(valid))
|
||||
(self.evidence / target / 'manual.json').unlink()
|
||||
with self.assertRaises(FileNotFoundError): self.gate()
|
||||
|
||||
def test_missing_corrupt_dirty_and_mismatched_packages(self):
|
||||
first = next(iter(d.TARGETS))
|
||||
self.manifests[first]['source_dirty'] = True
|
||||
with self.assertRaises(ValueError): self.gate()
|
||||
self.manifests[first]['source_dirty'] = False
|
||||
path = self.packages / d.name(self.version, first)
|
||||
path.write_bytes(b'broken')
|
||||
with self.assertRaises(ValueError): self.gate()
|
||||
path.unlink()
|
||||
with self.assertRaises(ValueError): self.gate()
|
||||
|
||||
def test_archive_traversal_rejected_before_extract(self):
|
||||
path = self.root / 'evil.tar.gz'
|
||||
with tarfile.open(path, 'w:gz') as tar:
|
||||
entry = tarfile.TarInfo('../escape'); tar.addfile(entry)
|
||||
dest = self.root / 'unpack'; dest.mkdir()
|
||||
with self.assertRaises(ValueError): d.unpack(path, dest, list(d.TARGETS)[1])
|
||||
self.assertFalse((self.root / 'escape').exists())
|
||||
|
||||
def test_upload_failure_never_publishes_or_overwrites(self):
|
||||
class API:
|
||||
def __init__(self):
|
||||
self.release = dict(id=1, draft=True, assets=[])
|
||||
self.data = {}; self.fail_at = None; self.writes = []
|
||||
def request(self, method, path, data=None):
|
||||
if path.startswith('/tags/'): return {'commit': {'sha': 'a'*40}}
|
||||
if method == 'GET': return copy.deepcopy(self.release)
|
||||
self.writes.append((method,path))
|
||||
if method == 'POST':
|
||||
name = path.split('name=')[1]
|
||||
if name == self.fail_at: raise OSError('simulierter Uploadfehler')
|
||||
self.data[name] = data
|
||||
self.release['assets'].append(dict(name=name, browser_download_url=name))
|
||||
return {}
|
||||
if method == 'PATCH': self.release.update(data)
|
||||
def download(self, asset): return self.data[asset['name']]
|
||||
api = API(); api.fail_at = r.expected(self.version)[2]
|
||||
with self.assertRaises(OSError): r.stage(api, self.packages, 'v'+self.version, self.revision)
|
||||
self.assertTrue(api.release['draft'])
|
||||
self.assertFalse(any(method=='PATCH' for method,_ in api.writes))
|
||||
api.fail_at = None
|
||||
r.stage(api, self.packages, 'v'+self.version, self.revision)
|
||||
r.publish(api, self.packages, self.evidence, 'v'+self.version, self.revision)
|
||||
self.assertFalse(api.release['draft'])
|
||||
before = list(api.writes)
|
||||
with self.assertRaises(ValueError): r.stage(api, self.packages, 'v'+self.version, self.revision)
|
||||
self.assertEqual(before, api.writes)
|
||||
|
||||
def test_incomplete_remote_upload_blocks_publish(self):
|
||||
class API:
|
||||
def request(self, method, path, data=None):
|
||||
self.assert_get(method)
|
||||
if path.startswith('/tags/'): return {'commit': {'sha': 'a'*40}}
|
||||
return dict(id=1, draft=True, assets=[])
|
||||
@staticmethod
|
||||
def assert_get(method):
|
||||
if method != 'GET': raise AssertionError('Unerwartete Mutation')
|
||||
with self.assertRaises(ValueError): r.publish(API(), self.packages, self.evidence, 'v'+self.version, self.revision)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user