Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/mf33-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ name: MF-33 verified Mac release

on:
push:
branches: [codex/mf-33-release]
branches: [codex/mf-33-auto-update]
paths:
- serverbox/**
- scripts/native_acceptance.py
- scripts/test_pty_native.py
- scripts/package_unified_macos.py
- scripts/package_mac_preview.py
- scripts/test_mac_updater.py
- scripts/test_queue_dart.py
- .github/workflows/mf33-release.yml
- gpuqueue/**
- tests/**
Expand Down Expand Up @@ -45,6 +47,9 @@ jobs:
mkdir -p "$HOME/scratch"
python3 -m unittest discover -s tests -p 'test_*.py'
node --test tests/cloud.test.js tests/modes.test.cjs tests/widget.test.cjs tests/widget-update.test.cjs
- name: Exercise native installer and rollback with signed disposable apps
working-directory: source
run: python3 scripts/test_mac_updater.py
- name: Prepare the pinned Flutter SDK
run: |
git init flutter-sdk
Expand Down Expand Up @@ -83,10 +88,10 @@ jobs:
GH_TOKEN: ${{ github.token }}
CANDIDATE_SHA: ${{ github.sha }}
run: |
tag=v2.0.2003
tag=v2.0.2004
gh release create "$tag" build/unified-release/* \
--repo Ferryyer/gpu-queue --target "$CANDIDATE_SHA" \
--latest --title 'GPU Queue 2.0.2003 · GPU 申请修复版' \
--latest --title 'GPU Queue 2.0.2004 · 应用内自动安装更新' \
--notes-file build/unified-release/RELEASE-NOTES.md
- name: Upload native evidence and unsigned QA app
if: always()
Expand All @@ -96,6 +101,7 @@ jobs:
path: |
source/build/native-acceptance/
source/build/unified-release/
source/build/mac-updater-tests/
if-no-files-found: warn
retention-days: 7
- name: Save native compiler cache even if app compilation fails
Expand Down
11 changes: 11 additions & 0 deletions docs/MAC-AUTO-UPDATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
应用内安装从 2.0.2004 起提供。2.0.2003 及更早版本仍须手动下载并覆盖一次,以获得安装器。

检查更新后点击“下载并安装”,应用显示下载和校验进度,再退出、替换并重启。更新会断开本地 SSH 终端连接,远端任务继续运行。安装器不修改用户配置、钥匙串或服务器代码。

安装前验证正式 GitHub tag 的精确版本资产、字节数和 SHA-256,原生进程再次校验摘要、DMG、bundle ID、递增 build、CPU 架构及代码签名。只有准备好同目录暂存副本后才要求应用退出。不能写入当前应用目录时保留原应用,并提示迁移至可写的 Applications 文件夹;不使用 sudo,也不改动 Gatekeeper 设置。

独立的 `Contents/Helpers/GPUQueueUpdater` 等待旧进程退出,将原应用移至同目录的 `GPU Queue.previous-<build>-<UUID>.app`,放入新版并启动。新版首次渲染后通过原生通道确认启动。45 秒内无确认则停止本次启动的新进程、恢复备份并重开旧版。退出超时或取消不会替换应用。突然断电、安装器被外部终止等情况可能需要从备份手动恢复。

状态和日志位于用户的 `Library/Application Support/GPU Queue/Updater` 中。失败后可通过设置重新检查更新;如已回滚,下一次打开会提示结果。旧版备份保留供用户自行清理。

`scripts/test_mac_updater.py` 必须在真实 Mac 前台会话中运行。它编译生产安装器,构造并签名临时 app/DMG,验证正常更新重启、错误摘要、错误 bundle ID、退出超时、启动回滚和取消。发布流程只有在六项测试及完整原生构建通过后才打包发布,相关报告随 Release 提供。测试使用隔离的临时应用,不访问真实应用、SSH 或钥匙串。
6 changes: 5 additions & 1 deletion scripts/native_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,17 @@ def preflight(args, report):

def test_commands(args):
return [
([args.flutter, 'analyze', '--no-pub', '--no-fatal-infos', '--no-fatal-warnings',
'lib/core/update', 'lib/main.dart', 'packages/fl_lib/lib/src/core/update.dart',
'test/unit/app/mac_update_release_test.dart'], APP),
([sys.executable, 'scripts/test_pty_native.py', '--flutter', args.flutter], ROOT),
(['swiftc', '-swift-version', '5', 'scripts/macos-reconnect/TunnelRecovery.swift',
'-o', str(args.out.resolve() / 'TunnelRecovery')], APP),
([sys.executable, '-m', 'unittest', 'discover', '-s', 'scripts/macos-reconnect', '-v'], APP),
([args.flutter, 'test', '--no-pub', 'test/unit/app/queue_protocol_test.dart',
'test/unit/app/queue_session_test.dart', 'test/unit/app/queue_presentation_test.dart',
'test/unit/app/queue_resource_widget_test.dart', 'test/widget/terminal_reconnect_test.dart'], APP),
'test/unit/app/queue_resource_widget_test.dart', 'test/unit/app/mac_update_release_test.dart',
'test/widget/terminal_reconnect_test.dart'], APP),
]


Expand Down
23 changes: 18 additions & 5 deletions scripts/package_mac_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ def main():
tag = 'v'+version
if not report.get('macBuildVerified') or report.get('sourceCommit') != commit:
raise RuntimeError('Release requires native tests and build evidence for this exact commit')
installer_report = json.loads((ROOT / 'build/mac-updater-tests/report.json').read_text())
scenarios = {item['scenario'] for item in installer_report.get('scenarios', []) if item.get('passed')}
if not installer_report.get('native') or scenarios != {
'success', 'checksum_mismatch', 'wrong_bundle', 'quit_timeout', 'rollback', 'cancelled'}:
raise RuntimeError('Release requires all real macOS installer scenarios to pass')
helpers = app / 'Contents/Helpers'
helpers.mkdir(parents=True, exist_ok=True)
helper = helpers / 'GPUQueueUpdater'
subprocess.run(['swiftc', '-swift-version', '5', str(ROOT/'serverbox/macos/Updater/main.swift'),
'-o', str(helper)], check=True)
subprocess.run(['codesign', '--force', '--sign', '-', '--timestamp=none', str(helper)], check=True)
subprocess.run(['codesign', '--force', '--deep', '--sign', '-', '--timestamp=none', str(app)], check=True)
subprocess.run(['codesign', '--verify', '--deep', '--strict', str(app)], check=True)
out = ROOT / 'build/unified-release'
Expand All @@ -33,18 +44,20 @@ def main():
metadata.update(releaseTag=tag, channel='release', sourceCommit=commit,
architecture=arch, minimumMacOS='13.0', signature='ad-hoc',
notarized=False, launchSmokePassed=False, checksSkippedByUser=False,
interactiveSshSftpVerified=False, installationVerified=False)
interactiveSshSftpVerified=False, installationVerified=False,
inAppInstaller=True, installerNativeScenariosVerified=True)
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n')
(out / 'native-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
(out / 'installer-report.json').write_text(json.dumps(installer_report, ensure_ascii=False, indent=2) + '\n')
(out / 'RELEASE-NOTES.md').write_text(f'''GPU Queue Mac 发布版,版本 {version} ({build}),适用 Apple Silicon、macOS 13+。

源码:`{commit}`,分支 `codex/mf-33-release`。基于 2.0.2002 统一桌面应用,合入已合并的 PR #6。
源码:`{commit}`,分支 `codex/mf-33-auto-update`。基于 2.0.2003 GPU 申请修复版。

更新:旧 GPU 只读查询超时后,按个人偏好为已启动但未投放的任务补申请一次;重启不重复申请。修复 GPU 内部查询超时识别、旧通知重复触发探测和旧卡通知错误阻塞补申请。申请提交回执不明时仍保留原 UUID 核查;网页和原生任务详情显示真实阶段与申请编号。服务器适配器已在个人服务更新,安装客户端不会替换服务器代码。
更新:加入应用内“下载并安装”流程,显示下载进度,核对官方发布资产的大小及 SHA-256,并验证应用标识、版本、架构和代码签名。安装准备好后退出旧应用,保留同目录旧版备份,替换并重启;新版启动未确认时自动恢复上一版。SSH 配置、钥匙串和用户数据不参与替换。远端任务继续运行,本地终端连接会随应用重启断开。

下载 `GPU-Queue-macos-arm64-{build}-unsigned.dmg`,核对 SHA256SUMS 后挂载,退出旧应用并拖入 Applications。保留 SSH 配置和钥匙串。此包使用 ad-hoc 签名,未做 Apple Developer ID 签名或公证;如系统阻止打开,核对来源后在系统设置的隐私与安全性中选择“仍要打开”。
首次安装或从 2.0.2003 及更早版本升级:下载 `GPU-Queue-macos-arm64-{build}-unsigned.dmg`,核对 SHA256SUMS 后挂载,退出旧应用并拖入 Applications 覆盖一次。旧版没有安装器,无法自行安装本次升级;安装本版后,后续版本可在应用内完成。应用须位于可写目录,如 `~/Applications`;安装器不请求管理员权限。此包使用 ad-hoc 签名,未做 Apple Developer ID 签名或公证;如系统阻止打开,核对来源后在系统设置的隐私与安全性中选择“仍要打开”。

发布前执行 CPU 调度回归、JavaScript 更新兼容测试、Mac 原生环境检查、PTY/Swift/Flutter 测试及完整 Xcode 构建,并验证 ad-hoc 签名,原生结果见 native-report.json。真实服务器 SSH/SFTP、安装覆盖、休眠、字体和全屏的人工作业验收尚未在本轮完成;详见 docs/MAC-NATIVE-HANDOFF.md。保留旧 Widget 更新入口。若需回退,退出新版并恢复此前备份的应用。
发布前执行 CPU/JavaScript 回归、Dart 元数据校验、Mac 原生环境检查、PTY/Swift/Flutter 测试及完整 Xcode 构建,并验证 ad-hoc 签名。真实 macOS 临时应用覆盖测试包含安装重启、摘要错误、包标识错误、退出超时、启动失败回滚和取消六种场景,结果见 installer-report.json。完整应用的人工作业验收仍见 docs/MAC-NATIVE-HANDOFF.md。保留旧 Widget 更新入口。
''', encoding='utf-8')
# Every published file except the checksum manifest is covered.
(out / 'SHA256SUMS').write_text(''.join(
Expand Down
178 changes: 178 additions & 0 deletions scripts/test_mac_updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Exercise the real Swift installer against disposable signed fixture apps on macOS."""
import hashlib
import json
import os
from pathlib import Path
import plistlib
import signal
import subprocess
import sys
import tempfile
import time
import uuid

ROOT = Path(__file__).resolve().parent.parent
FIXTURE = r'''
import Cocoa
let app = NSApplication.shared
app.setActivationPolicy(.prohibited)
let info = Bundle.main.infoDictionary!
let receipt = URL(fileURLWithPath: info["FixtureReceipt"] as! String)
let build = info["CFBundleVersion"] as! String
let value: [String: Any] = ["pid": ProcessInfo.processInfo.processIdentifier, "build": build]
try! JSONSerialization.data(withJSONObject: value).write(to: receipt, options: .atomic)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
if info["FixtureAck"] as? Bool == true {
let status = URL(fileURLWithPath: info["FixtureStatus"] as! String)
if let data = try? Data(contentsOf: status),
var state = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
state["phase"] as? String == "awaiting_launch", state["build"] as? String == build {
state["phase"] = "complete"
try! JSONSerialization.data(withJSONObject: state).write(to: status, options: .atomic)
}
}
}
app.run()
'''


def run(*command):
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if result.returncode:
print(result.stdout.decode(errors='replace'), file=sys.stderr)
result.check_returncode()


def wait_for(predicate, timeout=40):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(.05)
raise AssertionError('Fixture deadline exceeded')


def read(path):
try:
return json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}


def main():
if sys.platform != 'darwin':
raise RuntimeError('Native installer acceptance requires macOS; no simulated pass')
out = ROOT / 'build/mac-updater-tests'
out.mkdir(parents=True, exist_ok=True)
helper = out / 'GPUQueueUpdater'
run('swiftc', '-swift-version', '5', str(ROOT/'serverbox/macos/Updater/main.swift'), '-o', str(helper))
results = []
with tempfile.TemporaryDirectory(prefix='gpuq-installer-') as folder:
root = Path(folder)
source = root/'Fixture.swift'
source.write_text(FIXTURE)
fixture = root/'Fixture'
run('swiftc', '-swift-version', '5', str(source), '-o', str(fixture))

for scenario in ('success', 'checksum_mismatch', 'wrong_bundle', 'quit_timeout', 'rollback', 'cancelled'):
case = root/scenario
case.mkdir()
work = case/'work'
work.mkdir()
status = case/'state/status.json'
receipt = case/'receipt.json'
destination = case/'GPU Queue.app'
bundle_id = 'work.gpuqueue.updater.fixture.' + uuid.uuid4().hex
pids = set()
child = None
log = (out/f'{scenario}.log').open('wb')
try:
def make_app(path, build, ack, identifier=bundle_id):
contents = path/'Contents'
(contents/'MacOS').mkdir(parents=True)
(contents/'MacOS/Fixture').write_bytes(fixture.read_bytes())
(contents/'MacOS/Fixture').chmod(0o755)
(contents/'Info.plist').write_bytes(plistlib.dumps({
'CFBundleExecutable': 'Fixture', 'CFBundleIdentifier': identifier,
'CFBundleVersion': str(build), 'CFBundleShortVersionString': f'2.0.{build}',
'CFBundleName': 'GPU Queue Fixture', 'CFBundlePackageType': 'APPL',
'LSUIElement': True, 'FixtureAck': ack, 'FixtureStatus': str(status),
'FixtureReceipt': str(receipt),
}))
run('codesign', '--force', '--sign', '-', str(path))

make_app(destination, 100, False)
payload = case/'payload'
make_app(payload/'GPU Queue.app', 101, scenario != 'rollback',
bundle_id if scenario != 'wrong_bundle' else 'work.gpuqueue.wrong')
dmg = work/'update.dmg'
run('hdiutil', 'create', '-srcfolder', str(payload), '-format', 'UDZO', str(dmg))
run('open', '-n', str(destination))
old = wait_for(lambda: read(receipt))
pids.add(old['pid'])
# User data is deliberately outside both application bundles.
data = case/'settings.json'
data.write_text('{"ssh":"keep","token":"fixture"}')
plan = {'id': str(uuid.uuid4()), 'destination': str(destination), 'dmg': str(dmg),
'sha256': hashlib.sha256(dmg.read_bytes()).hexdigest(), 'build': '101',
'bundleID': bundle_id, 'pid': old['pid'], 'status': str(status),
'quitTimeout': 1.5 if scenario == 'quit_timeout' else 15., 'launchTimeout': 3.}
if scenario == 'checksum_mismatch':
plan['sha256'] = '0' * 64
if scenario == 'cancelled':
(work/'cancel').touch()
planfile = work/'plan.json'
planfile.write_text(json.dumps(plan))
child = subprocess.Popen([str(helper), str(planfile)], stdout=log, stderr=log)
if scenario in ('success', 'rollback', 'quit_timeout'):
wait_for(lambda: read(status).get('phase') in ('ready', 'failed'))
assert read(status)['phase'] == 'ready', read(status)
if scenario in ('success', 'rollback'):
os.kill(old['pid'], signal.SIGTERM)
code = child.wait(timeout=55)
state = read(status)
installed = plistlib.loads((destination/'Contents/Info.plist').read_bytes())
if scenario == 'success':
assert code == 0 and state['phase'] == 'complete', state
assert installed['CFBundleVersion'] == '101'
assert Path(state['backup']).is_dir()
assert read(receipt)['build'] == '101'
elif scenario == 'rollback':
assert code == 0 and state['phase'] == 'rolled_back', state
assert installed['CFBundleVersion'] == '100'
wait_for(lambda: read(receipt).get('build') == '100' and read(receipt).get('pid') != old['pid'])
else:
assert code != 0 and state['phase'] == 'failed', state
assert installed['CFBundleVersion'] == '100'
os.kill(old['pid'], 0) # A failed preflight never terminates the old application.
assert data.read_text() == '{"ssh":"keep","token":"fixture"}'
results.append({'scenario': scenario, 'passed': True})
print(f'PASS {scenario}', flush=True)
except Exception:
log.flush()
print((out/f'{scenario}.log').read_text(errors='replace'), file=sys.stderr)
raise
finally:
if child and child.poll() is None:
child.terminate()
child.wait(timeout=10)
if read(receipt).get('pid'):
pids.add(read(receipt)['pid'])
for pid in pids:
try:
# Only a fixture PID whose executable remains inside this test directory.
command = subprocess.check_output(['ps', '-p', str(pid), '-o', 'comm='], text=True).strip()
if str(case) in command:
os.kill(pid, signal.SIGTERM)
except (ProcessLookupError, subprocess.CalledProcessError):
pass
if (work/'volume').is_mount():
run('hdiutil', 'detach', '-force', str(work/'volume'))
log.close()
(out/'report.json').write_text(json.dumps({'native': True, 'scenarios': results}, indent=2)+'\n')


if __name__ == '__main__':
main()
2 changes: 1 addition & 1 deletion scripts/test_queue_dart.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def main():
(harness/'test').mkdir()
ui = ' material_ui: 1.5.0\n cupertino_ui: 1.1.1\n' if args.widgets else ''
(harness/'pubspec.yaml').write_text("name: queue_protocol_checks\nenvironment:\n sdk: '>=3.11.0 <4.0.0'\ndependencies:\n flutter:\n sdk: flutter\n"+ui+"dev_dependencies:\n flutter_test:\n sdk: flutter\nflutter:\n uses-material-design: true\n")
names = ['queue_protocol_test.dart', 'queue_session_test.dart', 'queue_presentation_test.dart']
names = ['queue_protocol_test.dart', 'queue_session_test.dart', 'queue_presentation_test.dart', 'mac_update_release_test.dart']
if args.widgets:
names += ['queue_resource_widget_test.dart', 'queue_workspace_widget_test.dart']
for name in names:
Expand Down
Loading
Loading