#!/usr/bin/env python3 """Validate local release assets; use --submission-ready to require owner setup too.""" from pathlib import Path from PIL import Image,ImageStat import json,subprocess,plistlib,hashlib,sys R=Path(__file__).resolve().parents[1] errors=[];checks=[] def check(ok,label): (checks if ok else errors).append(label) for locale in ['en-GB','en-US']: for name,limit in [('name',30),('subtitle',30),('promotional_text',170),('description',4000),('keywords',100)]: value=(R/f'metadata/{locale}/{name}.txt').read_text().strip() check(bool(value) and (len(value.encode()) if name=='keywords' else len(value))<=limit,f'{locale}/{name}: length within {limit}') manifest=json.loads((R/'asset-manifest.json').read_text()) check(len(manifest['screenshots'])==12,'12 upload screenshots') for item in manifest['screenshots']: for name in [item['file'],item['source']]: im=Image.open(R/name) check(im.size==(item['width'],item['height']) and (im.mode=='RGB' or ('A' in im.getbands() and im.getchannel('A').getextrema()==(255,255))),f'{name}: dimensions and opacity') check(max(ImageStat.Stat(im.resize((100,100))).stddev)>15,f'{name}: nonblank image') im=Image.open(R/'icons/Tarnax-AppStore-1024.png') check(im.size==(1024,1024) and im.mode=='RGB','App Store icon: opaque 1024 square') for device,size in [('iphone-6.9',(886,1920)),('ipad-13',(1200,1600))]: file=R/f'previews/Tarnax-{device}-preview.mp4' if not file.exists(): check(False,f'{device}: preview exists');continue data=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_streams','-show_format','-of','json',str(file)])) video=next((s for s in data['streams'] if s['codec_type']=='video'),{}) audio=next((s for s in data['streams'] if s['codec_type']=='audio'),{}) check((video.get('width'),video.get('height'))==size and video.get('codec_name')=='h264' and video.get('level',999)<=40,f'{device}: video format') check(15<=float(data['format']['duration'])<=30 and file.stat().st_size<500_000_000,f'{device}: duration and size') check(audio.get('codec_name')=='aac' and audio.get('channels')==2 and audio.get('sample_rate')=='48000',f'{device}: audio format') a=plistlib.loads((R/'legal/PrivacyInfo.xcprivacy').read_bytes()) check(a==plistlib.loads((R.parent/'Tarnax/PrivacyInfo.xcprivacy').read_bytes()),'Privacy manifest matches shipping source') for name in ['index.html','support.html','privacy.html','terms.html','style.css']: check((R/'website'/name).exists(),f'Website {name}') config=json.loads((R/'release-config.json').read_text()) owner=[key for key in ['support_url','privacy_policy_url','price','territories'] if config[key] is None] if not config['review_contact']['phone']:owner.append('review contact phone') if not config['review_server']['https_url']:owner.append('dedicated reviewer server and credentials') if not (R/'build/latest.json').exists():owner.append('App Store signed IPA export') owner+=['Confirm naming rights, privacy answers, age rating, agreements and final physical-device QA'] report={'asset_checks_passed':len(checks),'asset_errors':errors,'submission_ready':not(errors or owner),'owner_actions':owner} (R/'validation-report.json').write_text(json.dumps(report,indent=2)+'\n') (R/'validation-report.md').write_text('# Release asset validation\n\n'+f'{len(checks)} checks passed. {len(errors)} asset errors.\n\n'+ '\n'.join('- '+v for v in errors)+'\n\n## Owner actions before submission\n\n'+'\n'.join('- '+v for v in owner)+'\n') files=sorted(p for p in R.rglob('*') if p.is_file() and 'build' not in p.relative_to(R).parts and p.name not in ['SHA256SUMS'] and '__pycache__' not in p.parts) (R/'SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest()+' '+str(p.relative_to(R))+'\n' for p in files)) print(json.dumps(report,indent=2)) sys.exit(1 if errors else 2 if '--submission-ready' in sys.argv and owner else 0)