fix: isolate CI Keychain access from the production app
Build / macos (push) Successful in 27s

This commit is contained in:
2026-09-10 00:33:30 +02:00
parent 31584792e6
commit 7584863392
3 changed files with 30 additions and 35 deletions
+3 -2
View File
@@ -88,8 +88,9 @@ identity keeps the same sandbox container across updates.
`.gitea/workflows/build.yml` builds universal macOS artifacts for pushes and pull `.gitea/workflows/build.yml` builds universal macOS artifacts for pushes and pull
requests to `main`. The repository-scoped `mac-mini-meetingnotes` runner uses the 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 `macos-arm64` label. Smoke tests use a separate CI bundle identifier and temporary
launch preferences, an internal-volume staging directory, and Launch Services launch preferences and an internal-volume staging directory. Keychain services
so the GUI app runs in the logged-in Mac session. Release jobs import the original Developer ID certificate 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 into a temporary keychain and keep the original Sparkle signing key in Gitea
Actions secrets. They never publish from a development branch. Actions secrets. They never publish from a development branch.
+1 -1
View File
@@ -8,7 +8,7 @@ import Security
class KeychainHelper { class KeychainHelper {
static let shared = KeychainHelper() static let shared = KeychainHelper()
private let serviceName = "net.jamesbone.meetingnotes" private let serviceName = Bundle.main.bundleIdentifier ?? "net.jamesbone.meetingnotes"
private init() {} private init() {}
+26 -32
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Check the CI app's unauthenticated API without changing saved preferences.""" """Check the CI app's unauthenticated API without changing saved preferences."""
import json import json
import os import plistlib
import signal
import socket import socket
import subprocess import subprocess
import sys import sys
@@ -19,23 +18,27 @@ with tempfile.TemporaryDirectory(prefix="smoke-", dir=cache) as directory:
staged = Path(directory) / "Meetingnotes.app" staged = Path(directory) / "Meetingnotes.app"
subprocess.run(["ditto", sys.argv[1], str(staged)], check=True) subprocess.run(["ditto", sys.argv[1], str(staged)], check=True)
app = staged / "Contents/MacOS/Meetingnotes" 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: with socket.socket() as listener:
listener.bind(("127.0.0.1", 0)) listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1] port = listener.getsockname()[1]
with tempfile.NamedTemporaryFile() as log: with tempfile.TemporaryFile() as log:
process = subprocess.Popen( 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, 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: try:
for attempt in range(30): for attempt in range(30):
if process.poll() is not None: 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") raise RuntimeError("Recording status allowed an unauthenticated request")
print("Local API readiness and authentication checks passed") print("Local API readiness and authentication checks passed")
except Exception: except Exception:
pid = app_pid() if process.poll() is None:
if pid is not None: sample = subprocess.run(["sample", str(process.pid), "1", "1"], capture_output=True, text=True, timeout=10)
sample = subprocess.run(["sample", str(pid), "1", "1"], capture_output=True, text=True, timeout=10) print(sample.stdout.split("Binary Images:")[0], file=sys.stderr)
print(sample.stdout[:14000], file=sys.stderr)
log.seek(0) log.seek(0)
print(log.read().decode(errors="replace")[-8000:], file=sys.stderr) print(log.read().decode(errors="replace")[-8000:], file=sys.stderr)
raise raise
finally: finally:
pid = app_pid() if process.poll() is None:
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
process.terminate() process.terminate()
process.wait(timeout=10) try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
clear_test_token()