diff --git a/README.md b/README.md index dbb5cb3..2304e3f 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ identity keeps the same sandbox container across updates. `.gitea/workflows/build.yml` builds universal macOS artifacts for pushes and pull requests to `main`. The repository-scoped `mac-mini-meetingnotes` runner uses the `macos-arm64` label. Smoke tests use a separate CI bundle identifier and temporary -launch preferences. Release jobs import the original Developer ID certificate +launch preferences, an internal-volume staging directory, and Launch Services +so the GUI app runs in the logged-in Mac session. Release jobs import the original Developer ID certificate into a temporary keychain and keep the original Sparkle signing key in Gitea Actions secrets. They never publish from a development branch. diff --git a/scripts/smoke_test_api.py b/scripts/smoke_test_api.py index e55cfc4..b30e5f3 100644 --- a/scripts/smoke_test_api.py +++ b/scripts/smoke_test_api.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Check the CI app's unauthenticated API without changing saved preferences.""" import json +import os +import signal import socket import subprocess import sys @@ -20,11 +22,20 @@ with tempfile.TemporaryDirectory(prefix="smoke-", dir=cache) as directory: with socket.socket() as listener: listener.bind(("127.0.0.1", 0)) port = listener.getsockname()[1] - with tempfile.TemporaryFile() as log: + with tempfile.NamedTemporaryFile() as log: process = subprocess.Popen( - [str(app), "-muteDeckAPIEnabled", "YES", "-muteDeckAPIPort", str(port), "-hasCompletedOnboarding", "YES", "-hasAcceptedTerms", "YES", "-SUEnableAutomaticChecks", "NO"], + ["open", "-n", "-W", "-g", "-a", str(staged), "--stdout", log.name, "--stderr", log.name, "--args", "-ApplePersistenceIgnoreState", "YES", "-muteDeckAPIEnabled", "YES", "-muteDeckAPIPort", str(port), "-hasCompletedOnboarding", "YES", "-hasAcceptedTerms", "YES", "-SUEnableAutomaticChecks", "NO"], stdout=log, stderr=subprocess.STDOUT, ) + def app_pid(): + # The unique staged path identifies only this test's app instance. + listing = subprocess.check_output(["ps", "-axo", "pid=,command="], text=True) + for line in listing.splitlines(): + fields = line.strip().split(None, 1) + if len(fields) == 2 and fields[1].startswith(str(app) + " "): + return int(fields[0]) + return None + try: for attempt in range(30): if process.poll() is not None: @@ -46,17 +57,27 @@ with tempfile.TemporaryDirectory(prefix="smoke-", dir=cache) as directory: raise RuntimeError("Recording status allowed an unauthenticated request") print("Local API readiness and authentication checks passed") except Exception: - if process.poll() is None: - sample = subprocess.run(["sample", str(process.pid), "1", "1"], capture_output=True, text=True, timeout=10) + pid = app_pid() + if pid is not None: + sample = subprocess.run(["sample", str(pid), "1", "1"], capture_output=True, text=True, timeout=10) print(sample.stdout[:14000], file=sys.stderr) log.seek(0) print(log.read().decode(errors="replace")[-8000:], file=sys.stderr) raise finally: - if process.poll() is None: - process.terminate() + pid = app_pid() + if pid is not None: try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - process.wait() + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + if app_pid() == pid and pid is not None: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.terminate() + process.wait(timeout=10)