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

This commit is contained in:
2026-09-07 21:50:05 +02:00
parent 796564795a
commit 32d3838002
40 changed files with 3134 additions and 11 deletions

23
.gitea/INSTALL.txt Normal file
View File

@@ -0,0 +1,23 @@
Terminal Basic — Terminalanwendung
Archiv in ein eigenes Verzeichnis entpacken. tb und tbc (Windows: .exe)
bleiben neben dem Verzeichnis runtimes/<Target-Triple>/ mit tbrt und tbrt.meta
(Windows: tbrt.exe bzw. tbrt.exe.meta). Diese Struktur zusammen aufbewahren.
IDE: ./tb
Windows: .\tb.exe
Ein interaktives Terminal mit RGB-Farben und mindestens 80x25 Zellen ist nötig.
Beispiel hello.bas: PRINT "Hallo" : END
./tbc check hello.bas
./tbc build hello.bas --exe --target <Target-Triple> -o hello
./hello
Windows: tbc.exe und hello.exe verwenden. Die Vorlage wird automatisch gefunden.
Target-Triple steht in manifest.json. BASIC-Erzeugung/Linken benötigt keinen
C-/Rust-Compiler oder nativen Linker. Auf macOS muss /usr/bin/codesign für die
lokale Finalisierung verfügbar sein. Das exportierte Executable enthält die
Runtime; benötigte eigene Datendateien liefert das Anwenderprogramm separat.
Die Pakete sind nicht Developer-ID-/Authenticode-signiert. OS-Vertrauens-
prüfungen bei heruntergeladenen Dateien und Terminalfunktionen bleiben Teil
der Zielabnahme. Build-Baselines sind keine gemessenen minimalen OS-Versionen;
verifizierte Systeme und Einschränkungen stehen im beigefügten Releasenachweis.

View 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')

View 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
View 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()

View 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
View 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
View 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()

View 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()

102
.gitea/workflows/build.yml Normal file
View File

@@ -0,0 +1,102 @@
name: Vierziel-Build und Pflichtabnahme
on:
push:
branches: ['main', 'ci/**']
tags: ['v*']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
checks:
if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository
runs-on: linux-arm64
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
timeout-minutes: 60
env:
CARGO_BUILD_JOBS: '2'
TB_VBDOS_REPOSITORY: ${{ vars.TB_VBDOS_REPOSITORY }}
TB_VBDOS_REPO: /tmp/tb-vbdos
TB_RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }}
steps:
- uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- name: Gepinnte Werkzeuge
run: sh .gitea/scripts/bootstrap.sh
- name: Fixierten Fremdbestand bereitstellen
run: |
set -eu
test -n "$TB_VBDOS_REPOSITORY" || { echo 'TB_VBDOS_REPOSITORY für Pflichtabnahme fehlt'; exit 1; }
git init "$TB_VBDOS_REPO"
git -C "$TB_VBDOS_REPO" fetch --depth=1 "$TB_VBDOS_REPOSITORY" 1cdd2b32b829fe1721d0b6aecc433abc47a96fb6
git -C "$TB_VBDOS_REPO" checkout --detach FETCH_HEAD
- name: Paket- und Release-Regressionen
run: python3 -m unittest discover -s .gitea/scripts -p 'test_*.py'
- name: Workspace, Inventar, Fremdprogramme und Referenzbenchmarks
run: python3 .gitea/scripts/acceptance.py
- uses: https://gitea.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
if: always()
with:
name: acceptance
path: acceptance/
if-no-files-found: error
build:
if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository
runs-on: linux-arm64
timeout-minutes: 90
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- target: x86_64-pc-windows-msvc
image: messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0
- target: aarch64-apple-darwin
image: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
- target: x86_64-unknown-linux-gnu
image: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
- target: aarch64-unknown-linux-gnu
image: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
container: ${{ matrix.image }}
env:
TB_BUILD_IMAGE: ${{ matrix.image }}
TB_TARGET: ${{ matrix.target }}
steps:
- uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- run: sh .gitea/scripts/bootstrap.sh
- name: Bauen, Format prüfen und paketieren
run: python3 .gitea/scripts/build.py --target "$TB_TARGET" --output dist
- uses: https://gitea.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: packages-${{ matrix.target }}
path: dist/
if-no-files-found: error
stage:
if: startsWith(gitea.ref, 'refs/tags/v') && gitea.event_name == 'push'
needs: [checks, build]
runs-on: linux-arm64
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
steps:
- uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- run: sh .gitea/scripts/bootstrap.sh
- uses: https://gitea.com/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
with:
path: artifacts
- run: cargo build --locked --release -p tb-export --bin tb-template
- name: Vollständigen Releaseentwurf erstellen
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
TB_RELEASE_TOKEN: ${{ secrets.TB_RELEASE_TOKEN }}
TB_TAG: ${{ gitea.ref_name }}
TB_REVISION: ${{ gitea.sha }}
run: |
mkdir packages
cp artifacts/packages-*/*.7z artifacts/packages-*/*.tar.gz artifacts/packages-*/*.sha256 packages/
cp artifacts/acceptance/acceptance.json packages/
python3 .gitea/scripts/release.py stage --packages packages --tag "$TB_TAG" --revision "$TB_REVISION"

View File

@@ -0,0 +1,32 @@
name: Geprüften Gitea-Entwurf freigeben
on:
workflow_dispatch:
inputs:
tag:
description: 'Vorhandener Versionstag, z.B. v0.1.0'
required: true
evidence_commit:
description: '40-stellige Origin-Revision mit release-evidence/<tag>/'
required: true
permissions:
contents: read
jobs:
publish:
if: gitea.ref == 'refs/heads/main'
runs-on: linux-arm64
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
steps:
- uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
fetch-depth: 0
persist-credentials: false
- run: sh .gitea/scripts/bootstrap.sh
- run: cargo build --locked --release -p tb-export --bin tb-template
- name: Originalpakete und revisionsgebundene Zielnachweise prüfen
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
TB_RELEASE_TOKEN: ${{ secrets.TB_RELEASE_TOKEN }}
TB_TAG: ${{ inputs.tag }}
TB_EVIDENCE_REVISION: ${{ inputs.evidence_commit }}
run: python3 .gitea/scripts/publish.py