vollständige umstellung auf gitea repo

This commit is contained in:
Simon Daniel Streich committed 2026-10-21 10:03:34 +02:00
1 parent 3a807da54c
commit 079e69963d
9 files changed
+422 -35

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
name: Update Scoop manifests
on:
workflow_dispatch:
schedule:
- cron: '0 * * * *'
jobs:
update:
runs-on: ubuntu-latest
permissions:
code: write
timeout-minutes: 55
steps:
- name: Checkout repository
uses: https://github.com/actions/checkout@v4
with:
ref: ${{ gitea.event.repository.default_branch }}
token: ${{ secrets.GITEA_TOKEN }}
fetch-depth: 0
- name: Check Python runtime
run: python3 -c "import sys; assert sys.version_info >= (3, 10), 'Python 3.10+ erforderlich'"
- name: Test manifest updater
run: python3 -B -m unittest discover -s tests -v
- name: Update manifests
id: update
run: python3 -B scripts/update-manifests.py --write
# Gültige Updates auch dann speichern, wenn ein anderer Download scheitert.
# Der fehlgeschlagene Update-Schritt bleibt im Workflow sichtbar.
- name: Commit and push changed manifests
if: ${{ always() && steps.update.outcome != 'skipped' }}
env:
DEFAULT_BRANCH: ${{ gitea.event.repository.default_branch }}
run: |
set -eu
git add -- bucket/*.json
if git diff --cached --quiet; then
echo 'Keine Manifest-Änderungen.'
exit 0
fi
git config user.name 'Gitea Actions'
git config user.email 'actions@gitea.medelexis.ch'
git commit -m 'Update Scoop manifests'
git pull --rebase origin "$DEFAULT_BRANCH"
git push origin "HEAD:$DEFAULT_BRANCH"
+3 -1
View File
@@ -1,5 +1,7 @@
*.bak *.bak
*.exe *.exe
scripts/* scripts/*
.forgejo/* !scripts/update-manifests.py
.vscode/* .vscode/*
__pycache__/
*.py[cod]
+1 -1
View File
@@ -5,7 +5,7 @@
"depends": [ "depends": [
"extras/element" "extras/element"
], ],
"url": "https://git.streichtech.ch/elexis/scoop/raw/branch/master/config/matrix/medelexis/config.json#/config.json", "url": "https://gitea.medelexis.ch/public/scoop/raw/branch/master/config/matrix/medelexis/config.json#/config.json",
"hash": "b5ed72c551ff700d86a4ae293c871528d71fa53440c46d2594916a6d1273d131", "hash": "b5ed72c551ff700d86a4ae293c871528d71fa53440c46d2594916a6d1273d131",
"post_install": [ "post_install": [
"$configDir = Join-Path $env:APPDATA 'Element'", "$configDir = Join-Path $env:APPDATA 'Element'",
-23
View File
@@ -1,23 +0,0 @@
{
"version": "13.6.0",
"url": "https://git.streichtech.ch/elexis/ee-iso-builder/releases/download/v13.6.0/ElexisEnvironment.iso#/ElexisEnvironment.dat",
"hash": "58fd7e00b7f4771fd4ee2165f3ebaff205cab780461340fc2c286f1b12faae90",
"installer": {
"script": "Copy-Item \"$dir\\$fname\" \"$HOME\\Downloads\\ElexisEnvironment.iso\" -Force"
},
"post_install": [
"$scoopCommand = (Get-Command scoop).Source",
"$cleanupCommand = \"Start-Sleep -Seconds 10; & '$scoopCommand' cleanup ee_iso --cache; & '$scoopCommand' uninstall ee_iso --purge\"",
"$cleanupBytes = [Text.Encoding]::Unicode.GetBytes($cleanupCommand)",
"$encodedCommand = [Convert]::ToBase64String($cleanupBytes)",
"Start-Process powershell.exe -WorkingDirectory $HOME -WindowStyle Hidden -ArgumentList @('-NoProfile', '-EncodedCommand', $encodedCommand)"
],
"checkver": {
"url": "https://git.streichtech.ch/api/v1/repos/elexis/ee-iso-builder/releases/latest",
"jsonpath": "$.tag_name",
"regex": "^v(?<version>[\\d.]+)$"
},
"autoupdate": {
"url": "https://git.streichtech.ch/elexis/ee-iso-builder/releases/download/v$version/ElexisEnvironment.iso#/ElexisEnvironment.dat"
}
}
+7 -7
View File
@@ -13,12 +13,12 @@
"extras/keepass", "extras/keepass",
"extras/teamviewer", "extras/teamviewer",
"ee-cli", "myelexis-cli",
"elexis_3.10", "elexis-3.10",
"elexis_3.12", "elexis-3.12",
"elexis_3.13", "elexis-3.13",
"elexis_master", "elexis-master",
"chat_medelexis" "chat-medelexis"
], ],
"url": "https://git.streichtech.ch/elexis/scoop/raw/branch/master/packages/p.intern.txt#/p.intern.txt" "url": "https://gitea.medelexis.ch/public/scoop/raw/branch/master/packages/p.intern.txt#/p.intern.txt"
} }
+2 -2
View File
@@ -33,7 +33,7 @@ if ($bucketList -match "elexis") {
Write-Host "elexis Bucket ist bereits vorhanden." -ForegroundColor White Write-Host "elexis Bucket ist bereits vorhanden." -ForegroundColor White
} }
else { else {
scoop bucket add elexis https://git.streichtech.ch/elexis/scoop scoop bucket add elexis https://gitea.medelexis.ch/public/scoop
Write-Host "elexis Bucket hinzugefügt." -ForegroundColor Green Write-Host "elexis Bucket hinzugefügt." -ForegroundColor Green
} }
@@ -47,6 +47,6 @@ else {
Write-Host "" Write-Host ""
Write-Host "Fertig! Verfügbare Apps anzeigen mit: scoop search" -ForegroundColor Cyan Write-Host "Fertig! Verfügbare Apps anzeigen mit: scoop search" -ForegroundColor Cyan
Write-Host "Bsp. scoop search ee-cli" -ForegroundColor Cyan Write-Host "Bsp. scoop search myelexis-cli" -ForegroundColor Cyan
Write-Host "" Write-Host ""
Read-Host "Enter drücken zum Beenden" Read-Host "Enter drücken zum Beenden"
+225
View File
@@ -0,0 +1,225 @@
"""Update timestamp and release manifests using only Python 3.10+.
Without --write, calculate changes without saving them. Never commit or push.
PowerShell statements in manifests are not executed.
"""
import argparse
from datetime import timezone
from email.utils import parsedate_to_datetime
import hashlib
import json
from pathlib import Path
import re
import sys
import tempfile
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urldefrag, urlsplit
from urllib.request import Request, urlopen
BUCKET = Path(__file__).resolve().parents[1] / 'bucket'
TIMESTAMP_REGEX = r'(?<version>\d{8}T\d{6}Z)'
def open_url(url, method='GET', headers=None):
parsed = urlsplit(url)
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password:
raise ValueError('Nur HTTPS-URLs ohne eingebettete Zugangsdaten sind erlaubt.')
return urlopen(Request(url, method=method, headers={
'User-Agent': 'Medelexis-Scoop-Updater', 'Cache-Control': 'no-cache',
'Accept-Encoding': 'identity', **(headers or {})}), timeout=180)
def metadata(headers):
return {key: headers.get(key, '') for key in ('Last-Modified', 'ETag')}
def read_head(url):
with open_url(url, 'HEAD') as response:
return metadata(response.headers)
def release_version(checkver):
if checkver.get('jsonpath') != '$.tag_name' or not checkver.get('regex'):
raise ValueError('Unterstützt werden Last-Modified oder jsonpath $.tag_name mit regex.')
with open_url(checkver['url']) as response:
tag = json.load(response)['tag_name']
pattern = checkver['regex'].replace('(?<version>', '(?P<version>')
match = re.search(pattern, tag)
if not match:
raise ValueError('Release-Tag passt nicht zum Versionsmuster.')
version = match.groupdict().get('version') or match.group(1)
if not re.fullmatch(r'[0-9A-Za-z][0-9A-Za-z._+-]*', version):
raise ValueError('Ungültige Release-Version.')
return version
def download_file(url, observed, target):
conditions = {}
if observed['ETag'] and not observed['ETag'].startswith('W/'):
conditions['If-Match'] = observed['ETag']
elif observed['Last-Modified']:
conditions['If-Unmodified-Since'] = observed['Last-Modified']
size = 0
prefix = b''
with open_url(url, headers=conditions) as response:
if metadata(response.headers) != observed:
raise ValueError('Datei hat sich zwischen HEAD und GET geändert; erneut versuchen.')
if response.headers.get('Content-Type', '').split(';')[0].lower() in (
'text/html', 'application/json'):
raise ValueError('Download liefert HTML/JSON statt einer Programmdatei.')
expected = response.headers.get('Content-Length')
total = int(expected) if expected is not None else None
last_report = time.monotonic()
if total is not None:
print(f' Download gestartet: {total / 1048576:.1f} MiB.', flush=True)
else:
print(' Download gestartet; Gesamtgrösse unbekannt.', flush=True)
with target.open('wb') as output:
while chunk := response.read(1024 * 1024):
if not size:
prefix = chunk[:4]
output.write(chunk)
size += len(chunk)
now = time.monotonic()
if now - last_report >= 5:
progress = f'{size / 1048576:.1f} MiB'
if total:
progress += f' / {total / 1048576:.1f} MiB ({size / total:.0%})'
print(f' Heruntergeladen: {progress}', flush=True)
last_report = now
if not size or (expected is not None and size != int(expected)):
raise ValueError('Leerer oder unvollständiger Download.')
suffix = Path(urlsplit(url).path).suffix.lower()
if suffix == '.exe' and not prefix.startswith(b'MZ'):
raise ValueError('Download hat keine Windows-EXE-Signatur.')
if suffix == '.zip' and not prefix.startswith(b'PK'):
raise ValueError('Download hat keine ZIP-Signatur.')
print(f' Download abgeschlossen: {size / 1048576:.1f} MiB.', flush=True)
return size
def hash_file(path):
total = path.stat().st_size
print(f' SHA-256-Berechnung gestartet: {total / 1048576:.1f} MiB.', flush=True)
digest = hashlib.sha256()
size = 0
last_report = time.monotonic()
with path.open('rb') as source:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
size += len(chunk)
now = time.monotonic()
if now - last_report >= 5:
print(f' Gehasht: {size / 1048576:.1f} / {total / 1048576:.1f} MiB '
f'({size / total:.0%})', flush=True)
last_report = now
print(' SHA-256-Berechnung abgeschlossen.', flush=True)
return digest.hexdigest()
def download_hash(url, observed):
with tempfile.TemporaryDirectory(prefix='scoop-update-') as directory:
target = Path(directory) / 'download.bin'
size = download_file(url, observed, target)
return hash_file(target), size
def save_manifest(path, manifest):
original = path.read_bytes()
newline = '\r\n' if b'\r\n' in original else '\n'
content = json.dumps(manifest, ensure_ascii=False, indent=4) + '\n'
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, suffix='.tmp', delete=False) as output:
temporary = Path(output.name)
output.write(content.replace('\n', newline).encode('utf-8'))
temporary.replace(path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def update_manifest(path, write=False):
manifest = json.loads(path.read_text(encoding='utf-8-sig'))
checkver, autoupdate = manifest.get('checkver'), manifest.get('autoupdate')
if not checkver or not autoupdate:
print(f'{path.stem}: kein automatisches Update konfiguriert; übersprungen.')
return False
if not isinstance(checkver, dict) or not isinstance(autoupdate.get('url'), str):
raise ValueError('Diese checkver/autoupdate-Konfiguration wird noch nicht unterstützt.')
template = autoupdate['url']
observed = None
if checkver.get('regex') == TIMESTAMP_REGEX:
source = urldefrag(manifest['url'])[0]
print(f'{path.stem}: HEAD-Anfrage an {source} ...', flush=True)
observed = read_head(source)
if not observed['Last-Modified']:
raise ValueError('Last-Modified-Header fehlt.')
date = parsedate_to_datetime(observed['Last-Modified'])
if date.tzinfo is None:
raise ValueError('Last-Modified enthält keine Zeitzone.')
version = date.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
if urldefrag(template.replace('$version', version))[0] != source:
raise ValueError('Zeitstempel-Updates benötigen eine feste Download-URL.')
elif 'script' not in checkver:
print(f'{path.stem}: Release-Version abfragen: {checkver.get("url", "")} ...', flush=True)
version = release_version(checkver)
else:
raise ValueError('Dieses checkver-Skript wird nicht ausgeführt oder interpretiert.')
if version == manifest['version']:
print(f'{path.stem}: {version} unverändert; kein Download.')
return False
url = template.replace('$version', version)
if '$' in url:
raise ValueError('Unterstützt wird nur der Platzhalter $version.')
source = urldefrag(url)[0]
if observed is None:
print(f'{path.stem}: HEAD-Anfrage an {source} ...', flush=True)
observed = read_head(source)
print(f'{path.stem}: neue Version {version}; GET-Download ...', flush=True)
print(' Für den SHA-256-Hash muss die gesamte Datei heruntergeladen werden. '
'Danach wird der Hash berechnet. Bei grossen Dateien kann dies mehrere Minuten dauern, '
'auch im Probelauf.', flush=True)
digest, size = download_hash(source, observed)
print(f'{path.stem}: {manifest["version"]} -> {version}; {size} Bytes; SHA-256 {digest}')
manifest.update(version=version, url=url, hash=digest)
if write:
save_manifest(path, manifest)
print(f'{path.stem}: Manifest gespeichert.')
else:
print(f'{path.stem}: Probelauf; Manifest bleibt unverändert.')
return True
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--bucket', type=Path, default=BUCKET)
parser.add_argument('--app', action='append', help='Manifestname; mehrfach möglich, sonst alle.')
parser.add_argument('--write', action='store_true', help='Geprüfte Änderungen speichern.')
args = parser.parse_args(argv)
if not args.bucket.is_dir():
parser.error('Bucket-Verzeichnis existiert nicht.')
if args.app and any(not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', app) for app in args.app):
parser.error('--app erwartet einen Manifestnamen ohne Pfad oder .json-Endung.')
paths = ([args.bucket / f'{app}.json' for app in args.app] if args.app else
sorted(args.bucket.glob('*.json')))
errors = 0
for path in paths:
try:
update_manifest(path, args.write)
except HTTPError as error:
print(f'{path.stem}: HTTP {error.code}; Manifest unverändert.', file=sys.stderr)
errors += 1
except (ValueError, KeyError, TypeError, OSError, URLError) as error:
print(f'{path.stem}: {error}; Manifest unverändert.', file=sys.stderr)
errors += 1
return int(errors > 0)
if __name__ == '__main__':
sys.exit(main())
-1
View File
@@ -1 +0,0 @@
123
+135
View File
@@ -0,0 +1,135 @@
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('updater', ROOT / 'scripts/update-manifests.py')
updater = importlib.util.module_from_spec(spec)
spec.loader.exec_module(updater)
MODIFIED = 'Wed, 21 Oct 2026 06:00:00 GMT'
HEADERS = {'Last-Modified': MODIFIED, 'ETag': '"example"'}
def response(data=b'', headers=None):
result = io.BytesIO(data)
result.headers = dict(headers or HEADERS)
return result
class UpdaterTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / 'myelexis-cli.json'
self.manifest = json.loads((ROOT / 'bucket/myelexis-cli.json').read_text(encoding='utf-8'))
self.path.write_text(json.dumps(self.manifest, indent=4) + '\n', encoding='utf-8')
self.original = self.path.read_bytes()
def test_same_version_only_uses_head(self):
self.manifest['version'] = '20261021T060000Z'
self.path.write_text(json.dumps(self.manifest), encoding='utf-8')
before = self.path.read_bytes()
with patch.object(updater, 'open_url', return_value=response()) as request:
self.assertFalse(updater.update_manifest(self.path, write=True))
self.assertEqual(request.call_count, 1)
self.assertEqual(request.call_args.args[1], 'HEAD')
self.assertEqual(self.path.read_bytes(), before)
def test_dry_run_downloads_and_hashes_without_writing(self):
binary = b'MZexample'
with patch.object(updater, 'open_url', side_effect=[response(), response(binary)]) as request:
self.assertTrue(updater.update_manifest(self.path))
self.assertEqual(request.call_args.kwargs['headers']['If-Match'], HEADERS['ETag'])
self.assertEqual(self.path.read_bytes(), self.original)
def test_write_updates_only_version_url_and_hash(self):
binary = b'MZexample'
with patch.object(updater, 'open_url', side_effect=[response(), response(binary)]):
updater.update_manifest(self.path, write=True)
actual = json.loads(self.path.read_text())
expected = {**self.manifest, 'version': '20261021T060000Z',
'hash': hashlib.sha256(binary).hexdigest()}
self.assertEqual(actual, expected)
def test_download_errors_leave_manifest_unchanged(self):
cases = [
response(b'MZexample', {**HEADERS, 'ETag': '"changed"'}),
response(b'MZexample', {**HEADERS, 'Content-Length': '100'}),
response(b'<html>login</html>', {**HEADERS, 'Content-Type': 'text/html'}),
response(b'not-an-executable'),
]
for download in cases:
with self.subTest(headers=download.headers):
with patch.object(updater, 'open_url', side_effect=[response(), download]):
with self.assertRaises(ValueError):
updater.update_manifest(self.path, write=True)
self.assertEqual(self.path.read_bytes(), self.original)
def test_missing_last_modified_leaves_manifest_unchanged(self):
with patch.object(updater, 'open_url', return_value=response(headers={'ETag': 'x'})):
with self.assertRaises(ValueError):
updater.update_manifest(self.path, write=True)
self.assertEqual(self.path.read_bytes(), self.original)
def test_hash_runs_after_download_and_temporary_file_is_removed(self):
binary = b'MZexample'
temporary_paths = []
original_hash = updater.hash_file
def inspect_download(path):
self.assertEqual(path.read_bytes(), binary)
temporary_paths.append(path)
return original_hash(path)
with patch.object(updater, 'open_url', return_value=response(binary)), \
patch.object(updater, 'hash_file', side_effect=inspect_download):
digest, size = updater.download_hash('https://example.org/tool.exe', HEADERS)
self.assertEqual(digest, hashlib.sha256(binary).hexdigest())
self.assertEqual(size, len(binary))
self.assertFalse(temporary_paths[0].parent.exists())
def test_existing_release_manifest_substitutes_version(self):
manifest = {
'version': '13.6.0',
'url': 'https://example.org/releases/download/v13.6.0/tool.exe',
'hash': 'a' * 64,
'checkver': {'url': 'https://example.org/api/releases/latest',
'jsonpath': '$.tag_name', 'regex': r'^v(?<version>[\d.]+)$'},
'autoupdate': {'url': 'https://example.org/releases/download/v$version/tool.exe'},
}
self.path.write_text(json.dumps(manifest), encoding='utf-8')
binary = b'MZexample executable'
with patch.object(updater, 'open_url', side_effect=[
response(b'{"tag_name":"v13.7.0"}'), response(), response(binary)]) as request:
updater.update_manifest(self.path, write=True)
updated = json.loads(self.path.read_text())
self.assertEqual(updated['version'], '13.7.0')
self.assertIn('/v13.7.0/', updated['url'])
self.assertNotIn('#', request.call_args.args[0])
self.assertEqual(updated['hash'], hashlib.sha256(binary).hexdigest())
def test_archive_filename_is_updated_but_download_url_has_no_fragment(self):
manifest = json.loads((ROOT / 'bucket/elexis-3.13.json').read_text(encoding='utf-8'))
self.path.write_text(json.dumps(manifest), encoding='utf-8')
with patch.object(updater, 'open_url', side_effect=[response(), response(b'PKexample')]) as request:
updater.update_manifest(self.path, write=True)
self.assertNotIn('#', request.call_args.args[0])
self.assertTrue(json.loads(self.path.read_text())['url'].endswith('#/Elexis-20261021T060000Z.zip'))
def test_http_error_is_reported_and_does_not_stop_other_manifests(self):
other = self.path.with_name('other.json')
other.write_text(json.dumps({'version': '1.0.0'}), encoding='utf-8')
error = updater.HTTPError('https://example.org/file', 401, 'Unauthorized', {}, None)
with patch.object(updater, 'open_url', side_effect=error):
self.assertEqual(updater.main(['--bucket', self.temp.name, '--write']), 1)
self.assertEqual(self.path.read_bytes(), self.original)
if __name__ == '__main__':
unittest.main()