Compare commits

..
4 Commits
Author SHA1 Message Date
coder 4d87288f2f ci: stage the Mac launch check on the internal volume
Build / macos (push) Successful in 26s
2026-09-09 23:56:02 +02:00
coder cda28cc6be ci: preserve evidence when the Mac smoke launch stalls
Build / macos (push) Failing after 1m1s
2026-09-09 23:51:29 +02:00
coder df23c84474 ci: release notarized Meetingnotes updates through Gitea
Build / macos (push) Failing after 57s
2026-09-09 23:49:37 +02:00
coder 2508c77f17 ci: build and smoke test main on the Gitea Mac runner
Build / macos (push) Failing after 1m11s
2026-09-09 23:45:46 +02:00
10 changed files with 279 additions and 14 deletions
+51
View File
@@ -0,0 +1,51 @@
name: Build
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
jobs:
macos:
runs-on: macos-arm64
steps:
- uses: actions/checkout@v4
- name: Build Meetingnotes
run: >-
xcodebuild
-project Meetingnotes.xcodeproj
-scheme meetingnotes
-configuration Release
-destination 'generic/platform=macOS'
-derivedDataPath "$RUNNER_TEMP/DerivedData"
ARCHS="arm64 x86_64"
ONLY_ACTIVE_ARCH=NO
PRODUCT_BUNDLE_IDENTIFIER=net.jamesbone.meetingnotes.ci
CODE_SIGNING_ALLOWED=NO
build
- name: Sign test build
run: |
app_path="$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app"
codesign --force --deep --sign - \
--entitlements meetingnotes/meetingnotes.entitlements \
"$app_path"
codesign --verify --deep --strict "$app_path"
codesign -d --entitlements :- "$app_path" 2>&1 \
| grep -q 'com.apple.security.network.server'
- name: Smoke test local API
run: python3 scripts/smoke_test_api.py "$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app"
- name: Package test build
if: always()
run: |
app_path="$RUNNER_TEMP/DerivedData/Build/Products/Release/Meetingnotes.app"
ditto -c -k --sequesterRsrc --keepParent \
"$app_path" "$RUNNER_TEMP/Meetingnotes-macOS.zip"
- name: Upload test build
if: always()
uses: actions/upload-artifact@v4
with:
name: Meetingnotes-macOS-${{ github.sha }}
path: ${{ runner.temp }}/Meetingnotes-macOS.zip
retention-days: 30
+47
View File
@@ -0,0 +1,47 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: Version from MARKETING_VERSION, without the v prefix
required: true
type: string
permissions:
contents: write
concurrency:
group: meetingnotes-release
cancel-in-progress: false
jobs:
release:
runs-on: macos-arm64
env:
VERSION: ${{ inputs.version }}
RELEASE_BASE_URL: https://git.jamesbone.net/coder/meetingnotes
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
steps:
- name: Require main
run: test "$GITHUB_REF" = refs/heads/main
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build, sign, and notarize release
timeout-minutes: 45
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: python3 scripts/with_signing_identity.py scripts/package_release.sh
- name: Preserve signed release artifacts
uses: actions/upload-artifact@v4
with:
name: meetingnotes-signed-release-${{ github.run_id }}
path: ${{ runner.temp }}/meetingnotes-release/release
if-no-files-found: error
retention-days: 30
- name: Publish Gitea release
env:
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/publish_gitea_release.py
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 44;
CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.32;
MARKETING_VERSION = 1.1.33;
ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 44;
CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.32;
MARKETING_VERSION = 1.1.33;
ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+18 -3
View File
@@ -46,7 +46,7 @@ Later:
## Releasing a New Version
Production releases are Developer ID signed, notarized by Apple, published to
GitHub Releases, and signed for Sparkle auto-updates.
[Gitea Releases](https://git.jamesbone.net/coder/meetingnotes/releases), and signed for Sparkle auto-updates.
### Release Process
@@ -68,12 +68,12 @@ GitHub Releases, and signed for Sparkle auto-updates.
2. Commit and push the version change to `main`.
3. Run the `Release` workflow from GitHub Actions and enter the version without
3. Run the `Release` workflow from Gitea Actions on `main` and enter the version without
the `v` prefix. The workflow signs and notarizes the app, generates the
signed appcast, creates the version tag, and publishes both release assets.
The app checks
`https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml`
`https://git.jamesbone.net/coder/meetingnotes/releases/download/latest/appcast.xml`
and installs later releases automatically through Sparkle.
### Recovering Meetings
@@ -82,3 +82,18 @@ The first Developer ID signed build may not automatically inherit data from an
older ad-hoc signed build. In Settings, use **Import Meetings...** and select the
old `Meetings` folder. After this one-time transition, the stable signing
identity keeps the same sandbox container across updates.
### Build runner and GitHub transition
`.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
into a temporary keychain and keep the original Sparkle signing key in Gitea
Actions secrets. They never publish from a development branch.
Version 1.1.33 moves the embedded update feed to Gitea. After its notarized archive
is verified, replace the `appcast.xml` asset on the last GitHub release with the
new appcast. Existing installations discover the Gitea download through that old
GitHub feed; after installing it, they check Gitea directly. Keep the old GitHub
repository and its release appcast available for installations that update later.
+1 -1
View File
@@ -14,7 +14,7 @@
<key>NSMicrophoneUsageDescription</key>
<string>Meetingnotes needs access to your microphone for transcription.</string>
<key>SUFeedURL</key>
<string>https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml</string>
<string>https://git.jamesbone.net/coder/meetingnotes/releases/download/latest/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string>
<key>SUEnableAutomaticChecks</key>
+1 -1
View File
@@ -255,7 +255,7 @@ struct SettingsView: View {
// Link to GitHub repository
Link("GitHub",
destination: URL(string: "https://github.com/superdooper86/meetingnotes")!)
destination: URL(string: "https://git.jamesbone.net/coder/meetingnotes")!)
.foregroundColor(.blue)
// Link to landing page
+7 -5
View File
@@ -14,11 +14,13 @@ APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
required_variables=(
VERSION
SIGNING_IDENTITY
SIGNING_KEYCHAIN
APPLE_ID
APPLE_TEAM_ID
APPLE_APP_PASSWORD
SPARKLE_PRIVATE_KEY
GITHUB_REPOSITORY
RELEASE_BASE_URL
)
for variable in "${required_variables[@]}"; do
@@ -57,12 +59,12 @@ SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
SPARKLE_CONTENTS="$SPARKLE_FRAMEWORK/Versions/B"
sign_component() {
codesign --force --timestamp --options runtime --sign "$SIGNING_IDENTITY" "$1"
codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" --sign "$SIGNING_IDENTITY" "$1"
}
sign_component "$SPARKLE_CONTENTS/XPCServices/Installer.xpc"
if [[ -d "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" ]]; then
codesign --force --timestamp --options runtime \
codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" \
--preserve-metadata=entitlements \
--sign "$SIGNING_IDENTITY" \
"$SPARKLE_CONTENTS/XPCServices/Downloader.xpc"
@@ -71,7 +73,7 @@ sign_component "$SPARKLE_CONTENTS/Autoupdate"
sign_component "$SPARKLE_CONTENTS/Updater.app"
sign_component "$SPARKLE_FRAMEWORK"
codesign --force --timestamp --options runtime \
codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" \
--entitlements meetingnotes/meetingnotes.entitlements \
--sign "$SIGNING_IDENTITY" \
"$APP_PATH"
@@ -116,7 +118,7 @@ if [[ -z "$GENERATE_APPCAST" ]]; then
exit 1
fi
DOWNLOAD_URL="https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/"
DOWNLOAD_URL="$RELEASE_BASE_URL/releases/download/v$VERSION/"
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \
--ed-key-file - \
--download-url-prefix "$DOWNLOAD_URL" \
@@ -127,7 +129,7 @@ grep -q "$DOWNLOAD_URL$ARCHIVE_NAME" "$RELEASE_DIR/appcast.xml"
grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
printf 'Built, Developer ID-signed, notarized, and stapled Meetingnotes %s. The GitHub release is ready to publish.\n' \
printf 'Built, Developer ID-signed, notarized, and stapled Meetingnotes %s. The Gitea release is ready to publish.\n' \
"$VERSION" >> "$GITHUB_STEP_SUMMARY"
fi
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Publish both signed assets together, keeping incomplete uploads as a draft."""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
base = os.environ["GITEA_SERVER_URL"].rstrip("/") + "/api/v1/repos/" + os.environ["GITHUB_REPOSITORY"]
token = os.environ["GITEA_TOKEN"]
version = os.environ["VERSION"]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def request(path, method="GET", data=None, binary=False):
body = data if binary else None if data is None else json.dumps(data).encode()
req = urllib.request.Request(base + path, method=method, data=body, headers={
"Authorization": "token " + token,
"Content-Type": "application/octet-stream" if binary else "application/json",
"User-Agent": "Meetingnotes-release",
})
with urllib.request.build_opener(NoRedirect()).open(req, timeout=120) as response:
raw = response.read()
return json.loads(raw) if raw else None
release_dir = Path(os.environ["RUNNER_TEMP"]) / "meetingnotes-release/release"
assets = [release_dir / f"Meetingnotes-{version}.zip", release_dir / "appcast.xml"]
for asset in assets:
if not asset.is_file() or not asset.stat().st_size:
raise RuntimeError(f"Missing release artifact: {asset.name}")
release = request("/releases", "POST", {
"tag_name": "v" + version, "target_commitish": os.environ["GITHUB_SHA"],
"name": "Meetingnotes " + version, "draft": True, "prerelease": False,
"body": "Developer ID signed, Apple notarized, and signed for Sparkle updates.\n\nBuilt from main at `" + os.environ["GITHUB_SHA"] + "`.",
})
for asset in assets:
request(f"/releases/{release['id']}/assets?name=" + urllib.parse.quote(asset.name), "POST", asset.read_bytes(), binary=True)
request(f"/releases/{release['id']}", "PATCH", {"draft": False})
print("Published Meetingnotes " + version)
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Check the CI app's unauthenticated API without changing saved preferences."""
import json
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
# Launch from the Mac's internal volume; the runner checkout is on removable storage.
cache = Path.home() / "Library/Caches/meetingnotes-ci"
cache.mkdir(parents=True, exist_ok=True)
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 socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
with tempfile.TemporaryFile() as log:
process = subprocess.Popen(
[str(app), "-muteDeckAPIEnabled", "YES", "-muteDeckAPIPort", str(port), "-hasCompletedOnboarding", "YES", "-hasAcceptedTerms", "YES", "-SUEnableAutomaticChecks", "NO"],
stdout=log, stderr=subprocess.STDOUT,
)
try:
for attempt in range(30):
if process.poll() is not None:
raise RuntimeError("CI app exited before the API became ready")
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/info", timeout=2) as response:
info = json.load(response)
break
except (urllib.error.URLError, TimeoutError):
time.sleep(1)
else:
raise RuntimeError("CI API did not become ready")
assert info["name"] == "MeetingDebrief", "Unexpected API identity"
try:
urllib.request.urlopen(f"http://127.0.0.1:{port}/api/recording/status", timeout=2)
except urllib.error.HTTPError as error:
assert error.code == 401, f"Unexpected status: {error.code}"
else:
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)
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()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Import the CI certificate into an isolated keychain for one release command."""
import base64
import os
import re
import secrets
import subprocess
import sys
import tempfile
from pathlib import Path
def security(*args):
result = subprocess.run(["security", *args], capture_output=True, text=True)
if result.returncode:
raise RuntimeError(f"security {args[0]} failed (output withheld to protect credentials)")
return result.stdout
with tempfile.TemporaryDirectory(prefix="meetingnotes-signing-", dir=os.environ["RUNNER_TEMP"]) as directory:
keychain = str(Path(directory) / "release.keychain-db")
certificate = Path(directory) / "certificate.p12"
certificate.write_bytes(base64.b64decode(os.environ["APPLE_CERTIFICATE_P12"]))
certificate.chmod(0o600)
password = secrets.token_urlsafe(32)
created = False
try:
security("create-keychain", "-p", password, keychain)
created = True
security("set-keychain-settings", "-lut", "21600", keychain)
security("unlock-keychain", "-p", password, keychain)
security("import", str(certificate), "-k", keychain, "-P", os.environ["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign", "-T", "/usr/bin/security")
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychain)
identities = security("find-identity", "-v", "-p", "codesigning", keychain)
match = re.search(r'([0-9A-F]{40}) "Developer ID Application:', identities)
if not match:
raise RuntimeError("The certificate contains no valid Developer ID Application identity")
environment = dict(os.environ, SIGNING_IDENTITY=match[1], SIGNING_KEYCHAIN=keychain)
status = subprocess.run(sys.argv[1:], env=environment).returncode
finally:
if created:
security("delete-keychain", keychain)
sys.exit(status)