diff --git a/README.md b/README.md index 2304e3f..07af8e9 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,9 @@ 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, 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 +launch preferences and an internal-volume staging directory. Keychain services +follow the bundle identifier, so CI never reads production credentials; the smoke +test removes its isolated API token before and after each launch. 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/meetingnotes/Managers/KeychainHelper.swift b/meetingnotes/Managers/KeychainHelper.swift index 30db02b..244156c 100644 --- a/meetingnotes/Managers/KeychainHelper.swift +++ b/meetingnotes/Managers/KeychainHelper.swift @@ -8,7 +8,7 @@ import Security class KeychainHelper { static let shared = KeychainHelper() - private let serviceName = "net.jamesbone.meetingnotes" + private let serviceName = Bundle.main.bundleIdentifier ?? "net.jamesbone.meetingnotes" private init() {} diff --git a/scripts/smoke_test_api.py b/scripts/smoke_test_api.py index b30e5f3..a17bb6c 100644 --- a/scripts/smoke_test_api.py +++ b/scripts/smoke_test_api.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 """Check the CI app's unauthenticated API without changing saved preferences.""" import json -import os -import signal +import plistlib import socket import subprocess import sys @@ -19,23 +18,27 @@ with tempfile.TemporaryDirectory(prefix="smoke-", dir=cache) as directory: staged = Path(directory) / "Meetingnotes.app" subprocess.run(["ditto", sys.argv[1], str(staged)], check=True) app = staged / "Contents/MacOS/Meetingnotes" + with (staged / "Contents/Info.plist").open("rb") as info_file: + bundle_id = plistlib.load(info_file)["CFBundleIdentifier"] + assert bundle_id == "net.jamesbone.meetingnotes.ci", "Smoke tests require the isolated CI app" + + def clear_test_token(): + result = subprocess.run( + ["security", "delete-generic-password", "-s", bundle_id, "-a", "muteDeckAPIToken"], + capture_output=True, timeout=10, + ) + if result.returncode not in (0, 44): # 44: item does not exist + raise RuntimeError("Could not clear the CI Keychain token") + + clear_test_token() with socket.socket() as listener: listener.bind(("127.0.0.1", 0)) port = listener.getsockname()[1] - with tempfile.NamedTemporaryFile() as log: + with tempfile.TemporaryFile() as log: process = subprocess.Popen( - ["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"], + [str(app), "-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: @@ -57,27 +60,18 @@ 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: - 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) + if process.poll() is None: + sample = subprocess.run(["sample", str(process.pid), "1", "1"], capture_output=True, text=True, timeout=10) + print(sample.stdout.split("Binary Images:")[0], file=sys.stderr) log.seek(0) print(log.read().decode(errors="replace")[-8000:], file=sys.stderr) raise finally: - pid = app_pid() - if pid is not None: - try: - 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 + if process.poll() is None: process.terminate() - process.wait(timeout=10) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + clear_test_token()