vollständige umstellung auf gitea repo
This commit is contained in:
1 parent
3a807da54c
commit
079e69963d
9 files changed
+422
-35
No files matched your search
@@ -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
@@ -1,5 +1,7 @@
|
|||||||
*.bak
|
*.bak
|
||||||
*.exe
|
*.exe
|
||||||
scripts/*
|
scripts/*
|
||||||
.forgejo/*
|
!scripts/update-manifests.py
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
@@ -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'",
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user