Add reversible old-identity migration and freeze corrected release identities
这个提交包含在:
1 父节点
47f6e55ecd
当前提交
767bba8ac1
6 个文件已更改
+283
-26
没有与搜索条件相匹配的文件
+19
-17
@@ -3,26 +3,24 @@
|
||||
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.')
|
||||
args=parser.parse_args()
|
||||
bundle_id='com.dayu.fcpbeautygirl'
|
||||
lock=pathlib.Path('/Users/dayu/.codex/app-delivery-locks')/(bundle_id+'.lock')
|
||||
lock.parent.mkdir(parents=True,exist_ok=True)
|
||||
try:lock.mkdir()
|
||||
except FileExistsError:raise SystemExit('Delivery lock occupied; inspect owner.json and the owner process')
|
||||
(lock/'owner.json').write_text(json.dumps({'pid':os.getpid(),'cwd':str(root),'bundle_id':bundle_id}))
|
||||
staging=pathlib.Path('/tmp/paintfcp-build-20261021')
|
||||
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/FCP-BeautyGirl.app'
|
||||
installed=pathlib.Path('/Library/Plug-Ins/FxPlug/FCP-BeautyGirl.app')
|
||||
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},
|
||||
@@ -80,7 +78,7 @@ def cleanup_known_products():
|
||||
try:
|
||||
delivery_source={'commit':run(['git','rev-parse','HEAD']).strip(),
|
||||
'trackedWorktreeChanges':run(['git','status','--porcelain','--untracked-files=no']).splitlines()}
|
||||
plugin=app/'Contents/PlugIns/FCP-BeautyGirl XPC Service.pluginkit'
|
||||
plugin=app/'Contents/PlugIns'/PRODUCT['pluginName']
|
||||
if args.resume_prepared:
|
||||
old_receipt=json.loads(receipt_path.read_text())
|
||||
result=old_receipt.get('notarization',{})
|
||||
@@ -94,7 +92,7 @@ try:
|
||||
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,'--','FCP-BeautyGirl']).strip():raise RuntimeError('Product source changed since prepared build')
|
||||
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}
|
||||
@@ -107,14 +105,14 @@ try:
|
||||
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','FCP-BeautyGirl/FCP-BeautyGirl.xcodeproj','-scheme','Wrapper Application','-configuration','Release','-derivedDataPath',str(staging),'-destination','generic/platform=macOS','build'],logs/'build.log')
|
||||
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/'FCP-BeautyGirl/FCP-BeautyGirl/Wrapper Application/SandboxEntitlements.entitlements'),'--sign',identity,str(app)])
|
||||
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')
|
||||
@@ -148,6 +146,8 @@ try:
|
||||
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)])
|
||||
@@ -156,21 +156,23 @@ try:
|
||||
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/FCP-BeautyGirl XPC Service.pluginkit')])
|
||||
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/FCP-BeautyGirl XPC Service.pluginkit'
|
||||
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:
|
||||
@@ -179,11 +181,11 @@ try:
|
||||
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=cleanup_known_products()
|
||||
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 BaseException as error:
|
||||
save_receipt('failed',failure={'type':type(error).__name__,'message':str(error)})
|
||||
raise
|
||||
finally:
|
||||
(lock/'owner.json').unlink();lock.rmdir()
|
||||
locks.__exit__(None,None,None)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Add this product's reversible identity migration to PKG Forge's postinstall."""
|
||||
from product_identity import PRODUCT
|
||||
|
||||
def inject_migration(post, product=PRODUCT):
|
||||
legacy, = product['legacyProducts']
|
||||
block = r'''
|
||||
# FCP-BeautyGirl identity migration. Payload and template work are already complete.
|
||||
BG_NEW_APP="${TARGET_VOLUME%/}@@NEW_PATH@@"
|
||||
BG_OLD_APP="${TARGET_VOLUME%/}@@OLD_PATH@@"
|
||||
BG_BACKUP_DIR=""
|
||||
BG_OLD_MOVED=0
|
||||
bg_validate_identity() {
|
||||
local app="$1" expected="$2" plugin="$3" actual signature uuid
|
||||
[[ -d "$app" && ! -L "$app" ]] || return 1
|
||||
actual="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" || return 1
|
||||
[[ "$actual" == "$expected" ]] || return 1
|
||||
/usr/bin/codesign --verify --all-architectures --deep --strict "$app" || return 1
|
||||
signature="$(/usr/bin/codesign -dvvv "$app" 2>&1)" || return 1
|
||||
[[ "$signature" == *'TeamIdentifier=@@TEAM@@'* ]] || return 1
|
||||
uuid="$(/usr/libexec/PlistBuddy -c 'Print :ProPlugPlugInList:0:uuid' "$app/Contents/PlugIns/$plugin/Contents/Info.plist")" || return 1
|
||||
[[ "$uuid" == '@@UUID@@' ]]
|
||||
}
|
||||
bg_unregister_identity() {
|
||||
# Both bootstrap session and Unix identity belong to the desktop user.
|
||||
/bin/launchctl asuser "$CONSOLE_UID" /usr/bin/sudo -u "$CONSOLE_USER" /usr/bin/pluginkit -r "$1" >/dev/null 2>&1 || true
|
||||
}
|
||||
bg_rollback_identity() {
|
||||
local result=$?
|
||||
trap - EXIT
|
||||
if (( result != 0 && BG_OLD_MOVED )); then
|
||||
bg_validate_identity "$BG_NEW_APP" '@@NEW_ID@@' '@@NEW_PLUGIN@@' || {
|
||||
/bin/echo "新 APP 身份已变化,旧版保留在 $BG_BACKUP_DIR" >&2
|
||||
exit "$result"
|
||||
}
|
||||
bg_unregister_identity "$BG_NEW_APP/Contents/PlugIns/@@NEW_PLUGIN@@"
|
||||
/bin/rm -rf "$BG_NEW_APP"
|
||||
/bin/mv "$BG_BACKUP_DIR/@@OLD_NAME@@.app" "$BG_OLD_APP"
|
||||
/bin/rmdir "$BG_BACKUP_DIR"
|
||||
fx_verify_app "$BG_OLD_APP" 0 'Contents/PlugIns/@@OLD_PLUGIN@@' '@@OLD_PLUGIN_ID@@' '1.1' || true
|
||||
fi
|
||||
exit "$result"
|
||||
}
|
||||
bg_finish_identity() {
|
||||
if (( BG_OLD_MOVED )); then
|
||||
bg_validate_identity "$BG_BACKUP_DIR/@@OLD_NAME@@.app" '@@OLD_ID@@' '@@OLD_PLUGIN@@'
|
||||
/bin/rm -rf "$BG_BACKUP_DIR/@@OLD_NAME@@.app"
|
||||
/bin/rmdir "$BG_BACKUP_DIR"
|
||||
fi
|
||||
trap - EXIT
|
||||
}
|
||||
bg_validate_identity "$BG_NEW_APP" '@@NEW_ID@@' '@@NEW_PLUGIN@@' || {
|
||||
/bin/echo '新版 APP 签名或身份验证失败,未迁移旧版。' >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -e "$BG_OLD_APP" || -L "$BG_OLD_APP" ]]; then
|
||||
bg_validate_identity "$BG_OLD_APP" '@@OLD_ID@@' '@@OLD_PLUGIN@@' || {
|
||||
/bin/echo '旧路径的 APP 身份不属于本产品,未移动或删除。' >&2
|
||||
exit 1
|
||||
}
|
||||
BG_RECOVERY_ROOT="${TARGET_VOLUME%/}/Library/Application Support/@@NAME@@/Migration"
|
||||
/bin/mkdir -p "$BG_RECOVERY_ROOT"
|
||||
BG_BACKUP_DIR="$(/usr/bin/mktemp -d "$BG_RECOVERY_ROOT/.upgrade-XXXXXXXX")"
|
||||
bg_unregister_identity "$BG_OLD_APP/Contents/PlugIns/@@OLD_PLUGIN@@"
|
||||
/bin/mv "$BG_OLD_APP" "$BG_BACKUP_DIR/@@OLD_NAME@@.app"
|
||||
BG_OLD_MOVED=1
|
||||
fi
|
||||
trap bg_rollback_identity EXIT
|
||||
'''
|
||||
values = {'NEW_PATH': product['installationPath'], 'OLD_PATH': legacy['installationPath'],
|
||||
'NEW_ID': product['bundleIdentifier'], 'OLD_ID': legacy['bundleIdentifier'],
|
||||
'NEW_PLUGIN': product['pluginName'], 'OLD_PLUGIN': legacy['pluginName'],
|
||||
'OLD_PLUGIN_ID': legacy['pluginBundleIdentifier'], 'OLD_NAME': legacy['name'],
|
||||
'NAME': product['name'], 'TEAM': product['teamIdentifier'], 'UUID': product['filterUUID']}
|
||||
for key, value in values.items():
|
||||
if any(c in value for c in ['"', "'", '\n', '$', '`']):
|
||||
raise ValueError('Unsafe installer identity value: ' + key)
|
||||
block = block.replace('@@' + key + '@@', value)
|
||||
marker = '\ntrap - EXIT\nfx_verify_app '
|
||||
if post.count(marker) != 1 or not post.rstrip().endswith('exit 0'):
|
||||
raise ValueError('Unexpected PKG Forge postinstall structure')
|
||||
post = post.replace(marker, '\ntrap - EXIT\n' + block + '\nfx_verify_app ', 1)
|
||||
end = post.rfind('exit 0')
|
||||
return post[:end] + 'bg_finish_identity\n' + post[end:]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared product identity and reversible, identity-checked legacy APP migration."""
|
||||
import contextlib, json, os, pathlib, plistlib, shutil, subprocess
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
PRODUCT = json.loads((ROOT / 'product.json').read_text())
|
||||
|
||||
def validate_app(path, bundle_id, team=PRODUCT['teamIdentifier']):
|
||||
path = pathlib.Path(path)
|
||||
info = plistlib.loads((path / 'Contents/Info.plist').read_bytes())
|
||||
if info.get('CFBundleIdentifier') != bundle_id:
|
||||
raise RuntimeError('APP identity mismatch: ' + str(path))
|
||||
subprocess.run(['codesign', '--verify', '--all-architectures', '--deep', '--strict', str(path)], check=True, capture_output=True)
|
||||
signature = subprocess.run(['codesign', '-dvvv', str(path)], check=True, text=True, capture_output=True).stderr
|
||||
if ('TeamIdentifier=' + team) not in signature:
|
||||
raise RuntimeError('APP signing team mismatch: ' + str(path))
|
||||
plugins = list((path / 'Contents/PlugIns').glob('*.pluginkit'))
|
||||
if not any(PRODUCT['filterUUID'] in [p.get('uuid') for p in plistlib.loads((plugin / 'Contents/Info.plist').read_bytes()).get('ProPlugPlugInList', [])] for plugin in plugins):
|
||||
raise RuntimeError('APP does not contain this product filter UUID: ' + str(path))
|
||||
return info
|
||||
|
||||
@contextlib.contextmanager
|
||||
def delivery_locks(product=PRODUCT):
|
||||
"""Hold both old and new identity locks throughout an identity migration."""
|
||||
base = pathlib.Path('/Users/dayu/.codex/app-delivery-locks')
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
held = []
|
||||
try:
|
||||
for bundle_id in sorted({product['bundleIdentifier']} | {p['bundleIdentifier'] for p in product.get('legacyProducts', [])}):
|
||||
lock = base / (bundle_id + '.lock')
|
||||
lock.mkdir()
|
||||
held.append(lock)
|
||||
(lock / 'owner.json').write_text(json.dumps({'pid': os.getpid(), 'cwd': str(ROOT), 'bundle_id': bundle_id, 'product': product['name']}))
|
||||
yield
|
||||
finally:
|
||||
for lock in reversed(held):
|
||||
(lock / 'owner.json').unlink(missing_ok=True)
|
||||
lock.rmdir()
|
||||
|
||||
class LegacyMigration:
|
||||
def __init__(self, backup_root, *, product=PRODUCT, volume=pathlib.Path('/'), verifier=validate_app):
|
||||
self.product, self.backup_root, self.verifier = product, pathlib.Path(backup_root), verifier
|
||||
self.volume, self.moved = pathlib.Path(volume), []
|
||||
|
||||
def candidates(self):
|
||||
result = []
|
||||
for legacy in self.product.get('legacyProducts', []):
|
||||
source = self.volume / pathlib.Path(legacy['installationPath']).relative_to('/')
|
||||
if source.exists():
|
||||
self.verifier(source, legacy['bundleIdentifier'])
|
||||
backup = self.backup_root / ('legacy-' + legacy['name'] + '.app')
|
||||
if backup.exists():
|
||||
raise RuntimeError('Legacy recovery copy already exists: ' + str(backup))
|
||||
result.append((legacy, source, backup))
|
||||
return result
|
||||
|
||||
def relocate(self, unregister):
|
||||
# Validate every candidate before moving any of them.
|
||||
for legacy, source, backup in self.candidates():
|
||||
self.verifier(source, legacy['bundleIdentifier'])
|
||||
unregister(source / 'Contents/PlugIns' / legacy['pluginName'])
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(source), str(backup))
|
||||
self.moved.append((legacy, source, backup))
|
||||
|
||||
def rollback(self, register):
|
||||
for legacy, source, backup in reversed(self.moved):
|
||||
if source.exists():
|
||||
raise RuntimeError('Refusing to overwrite a new APP during rollback: ' + str(source))
|
||||
self.verifier(backup, legacy['bundleIdentifier'])
|
||||
shutil.move(str(backup), str(source))
|
||||
register(source / 'Contents/PlugIns' / legacy['pluginName'])
|
||||
self.moved.clear()
|
||||
|
||||
def cleanup(self):
|
||||
removed = []
|
||||
for legacy, source, backup in self.moved:
|
||||
self.verifier(backup, legacy['bundleIdentifier'])
|
||||
shutil.rmtree(backup)
|
||||
removed.append(str(backup))
|
||||
self.moved.clear()
|
||||
return removed
|
||||
在新工单中引用
屏蔽一个用户