文件
paintfcp-fxplug/scripts/deliver.py
T

200 行
14 KiB
Python
可执行文件

#!/usr/bin/env python3
"""Release, notarize and install the unique FxPlug app under its delivery lock.
The final prompt keeps the lock held while native-host validation is performed.
"""
import argparse, datetime, json, os, pathlib, plistlib, re, shutil, subprocess, sys, time
from product_identity import PRODUCT, LegacyMigration, delivery_locks, validate_app
root=pathlib.Path(__file__).resolve().parent.parent
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--wait-for-hosts',action='store_true',help='After notarization, retain the delivery lock and wait for Motion/FCP to exit naturally before installing; never control or quit either host.')
parser.add_argument('--user-tests',action='store_true',help='After non-UI installed-app verification, record native effect QA as pending user testing and clean known generated copies without the interactive validation prompt.')
parser.add_argument('--resume-prepared',action='store_true',help='Resume an already Accepted and stapled staged app after checking its recorded signature, version and unchanged product source.')
parser.add_argument('--prepare-only',action='store_true',help='Build, sign, notarize and staple a staged Release, then release the delivery lock without replacing the installed APP or touching running hosts.')
args=parser.parse_args()
class PreparedOnly(Exception):
pass
bundle_id=PRODUCT['bundleIdentifier']
locks=delivery_locks();locks.__enter__()
staging=pathlib.Path('/tmp/fcp-beautygirl-release')
logs=root/'build'/'delivery';logs.mkdir(parents=True,exist_ok=True)
app=staging/'Build/Products/Release'/(PRODUCT['name']+'.app')
installed=pathlib.Path(PRODUCT['installationPath'])
archive=logs/'notarization.zip'
identity='Developer ID Application: zhong luo (B3W25FX5H5)'
keychain='/Users/dayu/Library/Keychains/login.keychain-db'
previous=logs/'previous-FCP-BeautyGirl.app'
migration=LegacyMigration(logs)
receipt_path=logs/'delivery-receipt.json'
receipt={'status':'preparing','bundleIdentifier':bundle_id,'installationPath':str(installed),
'options':{'waitForHosts':args.wait_for_hosts,'userTests':args.user_tests},
'nativeEffectQA':{'status':'pending_user' if args.user_tests else 'pending_operator','performedByScript':False}}
def run(args, log=None):
if log:
with open(log,'w') as output:subprocess.run(args,cwd=root,stdout=output,stderr=subprocess.STDOUT,check=True)
return ''
return subprocess.run(args,cwd=root,text=True,capture_output=True,check=True).stdout
def save_receipt(status,**fields):
receipt.update(fields);receipt['status']=status
receipt['updatedAt']=datetime.datetime.now(datetime.timezone.utc).isoformat()
receipt_path.write_text(json.dumps(receipt,ensure_ascii=False,indent=2)+'\n')
def running_hosts():
# Read process state only. Match executable suffixes so renamed installation
# roots do not accidentally allow replacing a live host's plug-in connection.
suffixes=('/Motion.app/Contents/MacOS/Motion','/Final Cut Pro.app/Contents/MacOS/Final Cut Pro')
found=[]
for line in run(['ps','-axo','pid=,comm=']).splitlines():
columns=line.strip().split(None,1)
if len(columns)==2 and columns[1].endswith(suffixes):found.append({'pid':int(columns[0]),'executable':columns[1]})
return found
def await_host_exit():
active=running_hosts()
if active and not args.wait_for_hosts:
raise RuntimeError('Close Motion and Final Cut Pro before installing; installed app retained')
if active:
save_receipt('waiting_for_host_exit',runningHosts=active)
print('HOST_EXIT_REQUIRED: Motion/Final Cut Pro must exit naturally before installation. The delivery lock remains held; no host/UI action will be taken.',flush=True)
while active:
time.sleep(3)
active=running_hosts()
print('HOSTS_EXITED: continuing with installation under the same delivery lock.',flush=True)
save_receipt('ready_to_install',runningHosts=[])
def require_product(path):
info=plistlib.loads((path/'Contents/Info.plist').read_bytes())
if info.get('CFBundleIdentifier')!=bundle_id:raise RuntimeError('Generated/installed app identity mismatch: '+str(path))
return info
def unregister_generated_plugin(plugin):
# Removing an already absent registration can return 1. Establish absence
# by discovery rather than turning that idempotent state into a failure.
removed=subprocess.run(['pluginkit','-r',str(plugin)],capture_output=True,text=True)
info=plistlib.loads((plugin/'Contents/Info.plist').read_bytes())
discovered=run(['pluginkit','-m','-A','-D','-v','-i',info['CFBundleIdentifier']])
if str(plugin) in discovered:raise RuntimeError('Generated plug-in is still discoverable: '+removed.stderr)
def cleanup_known_products():
removed=[]
if previous.exists():
require_product(previous);shutil.rmtree(previous);removed.append(str(previous))
if staging.exists():
if app.exists():require_product(app)
shutil.rmtree(staging);removed.append(str(staging))
if archive.exists():archive.unlink();removed.append(str(archive))
return removed
try:
delivery_source={'commit':run(['git','rev-parse','HEAD']).strip(),
'trackedWorktreeChanges':run(['git','status','--porcelain','--untracked-files=no']).splitlines()}
plugin=app/'Contents/PlugIns'/PRODUCT['pluginName']
if args.resume_prepared:
old_receipt=json.loads(receipt_path.read_text())
result=old_receipt.get('notarization',{})
if result.get('status')!='Accepted':raise RuntimeError('No Accepted staged notarization to resume')
info=require_product(app)
run(['codesign','--verify','--deep','--strict',str(app)])
run(['xcrun','stapler','validate',str(app)])
signature=subprocess.run(['codesign','-dvvv',str(app)],capture_output=True,text=True,check=True).stderr
expected=re.search(r'^CDHash=(.+)$',old_receipt.get('stagingSignature',''),re.M)
actual=re.search(r'^CDHash=(.+)$',signature,re.M)
if not expected or not actual or expected.group(1)!=actual.group(1):raise RuntimeError('Prepared signature hash changed')
if info.get('CFBundleShortVersionString')!=old_receipt.get('version') or info.get('CFBundleVersion')!=old_receipt.get('build'):raise RuntimeError('Prepared version changed')
source_commit=old_receipt.get('source',{}).get('commit')
if not source_commit or run(['git','diff',source_commit,'--',PRODUCT['sourceRoot'],'product.json']).strip():raise RuntimeError('Product source changed since prepared build')
receipt.update(old_receipt);receipt.pop('failure',None)
receipt['resumedBy']=delivery_source
receipt['options']={'waitForHosts':args.wait_for_hosts,'userTests':args.user_tests,'resumePrepared':True}
receipt['nativeEffectQA']={'status':'pending_user' if args.user_tests else 'pending_operator','performedByScript':False}
save_receipt('resuming_prepared')
print('Previously Accepted and stapled Release verified; resuming installation.',flush=True)
else:
receipt['source']=delivery_source
save_receipt('building')
identities=run(['security','find-identity','-v','-p','codesigning',keychain])
if identity not in identities:raise RuntimeError('Valid Developer ID Application identity is unavailable')
print('Building universal Release…',flush=True)
run(['xcodebuild','-project',PRODUCT['xcodeProject'],'-scheme',PRODUCT['scheme'],'-configuration','Release','-derivedDataPath',str(staging),'-destination','generic/platform=macOS','build'],logs/'build.log')
# Xcode can register a fresh build. Remove this known generated path before
# lengthy signing/notarization while a user may still be testing the old app.
unregister_generated_plugin(plugin)
for framework in sorted((plugin/'Contents/Frameworks').glob('*.framework')):
run(['codesign','--force','--timestamp','--options','runtime','--sign',identity,str(framework)])
run(['codesign','--force','--timestamp','--options','runtime','--sign',identity,str(plugin)])
run(['codesign','--force','--timestamp','--options','runtime','--entitlements',str(root/PRODUCT['sourceDirectory']/'Wrapper Application/SandboxEntitlements.entitlements'),'--sign',identity,str(app)])
run(['codesign','--verify','--deep','--strict',str(app)])
info=require_product(app)
receipt['version']=info.get('CFBundleShortVersionString');receipt['build']=info.get('CFBundleVersion')
receipt['stagingSignature']=subprocess.run(['codesign','-dvvv',str(app)],capture_output=True,text=True,check=True).stderr
run(['ditto','-c','-k','--keepParent',str(app),str(archive)])
print('Submitting signed app to Apple notarization…',flush=True)
response=run(['xcrun','notarytool','submit',str(archive),'--keychain-profile','codex-dayu-notary-B3W25FX5H5','--keychain',keychain,'--wait','--output-format','json'])
(logs/'notarization.json').write_text(response)
result=json.loads(response)
receipt['notarization']=result
if result.get('status')!='Accepted':
if result.get('id'):
(logs/'notarization-details.json').write_text(run(['xcrun','notarytool','log',result['id'],'--keychain-profile','codex-dayu-notary-B3W25FX5H5','--keychain',keychain]))
raise RuntimeError('Apple did not accept notarization; original installed app retained')
run(['xcrun','stapler','staple',str(app)])
run(['xcrun','stapler','validate',str(app)])
# Keep a prepared build out of host discovery while the user still has the
# old installed version open. This removes only this known staging path.
unregister_generated_plugin(plugin)
save_receipt('prepared',signingVerified=True,stapleValidated=True,stagingPluginUnregistered=True)
if args.prepare_only:
print(json.dumps({'status':'prepared','path':str(app),'notarization':result,'installedAppUnchanged':True}),flush=True)
raise PreparedOnly()
# A live host retains its old XPC/OSC connection. Replacing the bundle while
# that connection exists can deadlock host shutdown or select stale code.
await_host_exit()
if installed.exists():
require_product(installed)
if previous.exists():raise RuntimeError('Previous recovery app already exists')
# Recheck immediately before touching the installed bundle. Waiting mode does
# not grant permission for a hot replacement if a host was opened again.
await_host_exit()
save_receipt('installing')
if installed.exists():
shutil.move(str(installed),str(previous))
try:
validate_app(app,bundle_id)
migration.relocate(unregister_generated_plugin)
shutil.copytree(app,installed,symlinks=True)
run(['codesign','--verify','--deep','--strict',str(installed)])
run(['xcrun','stapler','validate',str(installed)])
run(['spctl','--assess','--type','execute','--verbose=2',str(installed)])
info=plistlib.loads((installed/'Contents/Info.plist').read_bytes())
signature=subprocess.run(['codesign','-dvvv',str(installed)],capture_output=True,text=True,check=True).stderr
if info.get('CFBundleIdentifier')!=bundle_id or 'TeamIdentifier=B3W25FX5H5' not in signature:raise RuntimeError('Installed app identity mismatch')
(logs/'installed-signature.txt').write_text(signature)
run(['pluginkit','-a',str(installed/'Contents/PlugIns'/PRODUCT['pluginName'])])
# Xcode may register its build product. Only the installed plugin should
# be discoverable while native host validation is in progress.
unregister_generated_plugin(plugin)
installed_plugin=installed/'Contents/PlugIns'/PRODUCT['pluginName']
plugin_info=plistlib.loads((installed_plugin/'Contents/Info.plist').read_bytes())
discovered=run(['pluginkit','-m','-A','-D','-v','-i',plugin_info['CFBundleIdentifier']])
if str(installed_plugin) not in discovered:raise RuntimeError('PluginKit did not report the formal installed plug-in path')
(logs/'installed-discovery.txt').write_text(discovered)
save_receipt('installed',installedSignature=signature,installedSignatureVerified=True,
installedStapleValidated=True,gatekeeperAccepted=True,
identityMigration={'legacyInstallationsRemoved':[str(source) for legacy,source,backup in migration.moved], 'filterUUIDPreserved':PRODUCT['filterUUID']},
pluginDiscovery={'bundleIdentifier':plugin_info['CFBundleIdentifier'],'formalPath':str(installed_plugin),'output':discovered})
except Exception:
if installed.exists():shutil.rmtree(installed)
if previous.exists():shutil.move(str(previous),str(installed))
migration.rollback(lambda path:run(['pluginkit','-a',str(path)]))
raise
print(json.dumps({'status':'installed','path':str(installed),'notarization':result}),flush=True)
if args.user_tests:
print('USER_TESTS_PENDING: installed identity, ticket, Gatekeeper and formal-path discovery verified. Native effect QA is left to the user; no host/UI operation was performed.',flush=True)
else:
input('HOST_VALIDATION_PENDING: press Return only after host verification and duplicate inventory.\n')
receipt['nativeEffectQA']={'status':'operator_confirmed','performedByScript':False}
# Only this task’s known generated product copies are removed.
removed=migration.cleanup()+cleanup_known_products()
save_receipt('installed_user_qa_pending' if args.user_tests else 'complete',removedGeneratedPaths=removed)
print('Delivery staging and notarization archive cleaned.',flush=True)
except PreparedOnly:
pass
except BaseException as error:
save_receipt('failed',failure={'type':type(error).__name__,'message':str(error)})
raise
finally:
locks.__exit__(None,None,None)