#!/usr/bin/env python3 """Verify fresh packaging output; existing files are not proof of a build.""" import argparse import hashlib import json import os import plistlib import re import subprocess import sys import tempfile from pathlib import Path def run(args): result = subprocess.run(args, capture_output=True, text=True) if result.returncode: raise RuntimeError(f'{args[0]} failed: {(result.stderr or result.stdout).strip()}') return result.stdout + result.stderr def fingerprint(path): digest = hashlib.sha256() for item in sorted(path.rglob('*')) if path.is_dir() else [path]: stat = item.lstat() relative = item.relative_to(path) if item != path else path.name digest.update(f'{relative}:{stat.st_mode}:{stat.st_mtime_ns}:{stat.st_size}'.encode()) if item.is_symlink(): digest.update(os.readlink(item).encode()) elif item.is_file(): with item.open('rb') as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b''): digest.update(chunk) return digest.hexdigest() def artifacts(): root = Path(os.getenv('PACK_OUTPUT_DIR', 'dist')) paths = list(root.glob('*.app')) + list(root.glob('*.dmg')) if any(p.is_symlink() for p in paths): raise RuntimeError('Artifact paths must not be symlinks.') return {str(p): fingerprint(p) for p in paths} def source_info(): result = {} for key, args in [('sha', ['rev-parse', 'HEAD']), ('branch', ['branch', '--show-current']), ('dirty', ['status', '--porcelain'])]: proc = subprocess.run(['git', *args], capture_output=True, text=True) result[key] = (bool(proc.stdout.strip()) if key == 'dirty' else proc.stdout.strip()) if not proc.returncode else None return result def verify_signature(path, identity): run(['codesign', '--verify', '--deep', '--strict', str(path)]) details = run(['codesign', '-dv', '--verbose=4', str(path)]) teams = re.findall(r'^TeamIdentifier=(.+)$', details, re.M) authorities = re.findall(r'^Authority=(.+)$', details, re.M) identity_team = re.search(r'\(([^()]+)\)$', identity) expected = os.getenv('PACK_EXPECTED_TEAM_ID') or (identity_team.group(1) if identity_team else None) if not expected or teams != [expected] or not authorities or authorities[0] != identity: raise RuntimeError(f'Artifact signing identity or team differs from the selected identity: {path.name}') return {'identity': authorities[0], 'teamId': teams[0]} def verify_app(path, identity, notarized, require_ticket=True): signing = verify_signature(path, identity) with (path / 'Contents/Info.plist').open('rb') as stream: info = plistlib.load(stream) if not info.get('CFBundleIdentifier'): raise RuntimeError('App bundle identifier is missing.') if notarized: if require_ticket: run(['xcrun', 'stapler', 'validate', str(path)]) run(['spctl', '--assess', '--type', 'execute', '-vv', str(path)]) return {'name': path.name, 'bundleId': info['CFBundleIdentifier'], 'version': info.get('CFBundleShortVersionString'), 'build': info.get('CFBundleVersion'), **signing} def verify_dmg(path, identity, notarized): if notarized: verify_signature(path, identity) run(['xcrun', 'stapler', 'validate', str(path)]) run(['spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '-vv', str(path)]) with tempfile.TemporaryDirectory(prefix='codex-package-mount-') as temporary: mount = Path(temporary) / 'volume' run(['hdiutil', 'attach', str(path), '-readonly', '-nobrowse', '-mountpoint', str(mount)]) try: apps = list(mount.glob('*.app')) if len(apps) != 1: raise RuntimeError('Expected one app inside the DMG; use a project-specific adapter for other layouts.') app = verify_app(apps[0], identity, notarized, require_ticket=False) finally: run(['hdiutil', 'detach', str(mount)]) return {'embeddedApp': app, 'notarized': notarized} def verify(before, identity, notarized): current = artifacts() fresh = [Path(p) for p, value in current.items() if before['artifacts'].get(p) != value] if not fresh: raise RuntimeError('No new or changed app/DMG was produced by this run.') result = [] for path in fresh: metadata = verify_app(path, identity, notarized) if path.suffix == '.app' else verify_dmg(path, identity, notarized) result.append({'path': str(path.resolve()), 'fingerprint': current[str(path)], **metadata}) app_ids = {(item.get('embeddedApp') or item).get('bundleId') for item in result} if len(app_ids) != 1: raise RuntimeError('Artifacts belong to different apps; select a project-specific output directory.') return {'status': 'verified', 'source': before['source'], 'notarization': 'verified' if notarized else 'not requested', 'artifacts': result} def main(): parser = argparse.ArgumentParser() parser.add_argument('command', choices=['snapshot', 'verify']) parser.add_argument('--baseline') parser.add_argument('--identity') parser.add_argument('--notarized', action='store_true') args = parser.parse_args() if args.command == 'snapshot': print(json.dumps({'artifacts': artifacts(), 'source': source_info()})) return 0 if not args.baseline or not args.identity: parser.error('verify requires --baseline and --identity') receipt = verify(json.loads(Path(args.baseline).read_text()), args.identity, args.notarized) output = Path(os.getenv('PACK_RECEIPT_PATH', str(Path(os.getenv('PACK_OUTPUT_DIR', 'dist')) / 'skill-package-receipt.json'))) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n') print(json.dumps(receipt, ensure_ascii=False, indent=2)) return 0 if __name__ == '__main__': try: raise SystemExit(main()) except (RuntimeError, OSError, ValueError) as exc: print(f'Package verification failed: {exc}', file=sys.stderr) raise SystemExit(1)