This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user