ci: release notarized Meetingnotes updates through Gitea
Build / macos (push) Failing after 57s

This commit is contained in:
2026-09-09 23:49:37 +02:00
parent 2508c77f17
commit df23c84474
9 changed files with 171 additions and 15 deletions
+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_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 44; CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.32; MARKETING_VERSION = 1.1.33;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 44; CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.32; MARKETING_VERSION = 1.1.33;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+18 -3
View File
@@ -46,7 +46,7 @@ Later:
## Releasing a New Version ## Releasing a New Version
Production releases are Developer ID signed, notarized by Apple, published to 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 ### Release Process
@@ -68,12 +68,12 @@ GitHub Releases, and signed for Sparkle auto-updates.
2. Commit and push the version change to `main`. 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 the `v` prefix. The workflow signs and notarizes the app, generates the
signed appcast, creates the version tag, and publishes both release assets. signed appcast, creates the version tag, and publishes both release assets.
The app checks 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. and installs later releases automatically through Sparkle.
### Recovering Meetings ### 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 older ad-hoc signed build. In Settings, use **Import Meetings...** and select the
old `Meetings` folder. After this one-time transition, the stable signing old `Meetings` folder. After this one-time transition, the stable signing
identity keeps the same sandbox container across updates. 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> <key>NSMicrophoneUsageDescription</key>
<string>Meetingnotes needs access to your microphone for transcription.</string> <string>Meetingnotes needs access to your microphone for transcription.</string>
<key>SUFeedURL</key> <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> <key>SUPublicEDKey</key>
<string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string> <string>9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=</string>
<key>SUEnableAutomaticChecks</key> <key>SUEnableAutomaticChecks</key>
+1 -1
View File
@@ -255,7 +255,7 @@ struct SettingsView: View {
// Link to GitHub repository // Link to GitHub repository
Link("GitHub", Link("GitHub",
destination: URL(string: "https://github.com/superdooper86/meetingnotes")!) destination: URL(string: "https://git.jamesbone.net/coder/meetingnotes")!)
.foregroundColor(.blue) .foregroundColor(.blue)
// Link to landing page // Link to landing page
+7 -5
View File
@@ -14,11 +14,13 @@ APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
required_variables=( required_variables=(
VERSION VERSION
SIGNING_IDENTITY SIGNING_IDENTITY
SIGNING_KEYCHAIN
APPLE_ID APPLE_ID
APPLE_TEAM_ID APPLE_TEAM_ID
APPLE_APP_PASSWORD APPLE_APP_PASSWORD
SPARKLE_PRIVATE_KEY SPARKLE_PRIVATE_KEY
GITHUB_REPOSITORY GITHUB_REPOSITORY
RELEASE_BASE_URL
) )
for variable in "${required_variables[@]}"; do for variable in "${required_variables[@]}"; do
@@ -57,12 +59,12 @@ SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework"
SPARKLE_CONTENTS="$SPARKLE_FRAMEWORK/Versions/B" SPARKLE_CONTENTS="$SPARKLE_FRAMEWORK/Versions/B"
sign_component() { 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" sign_component "$SPARKLE_CONTENTS/XPCServices/Installer.xpc"
if [[ -d "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" ]]; then if [[ -d "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" ]]; then
codesign --force --timestamp --options runtime \ codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" \
--preserve-metadata=entitlements \ --preserve-metadata=entitlements \
--sign "$SIGNING_IDENTITY" \ --sign "$SIGNING_IDENTITY" \
"$SPARKLE_CONTENTS/XPCServices/Downloader.xpc" "$SPARKLE_CONTENTS/XPCServices/Downloader.xpc"
@@ -71,7 +73,7 @@ sign_component "$SPARKLE_CONTENTS/Autoupdate"
sign_component "$SPARKLE_CONTENTS/Updater.app" sign_component "$SPARKLE_CONTENTS/Updater.app"
sign_component "$SPARKLE_FRAMEWORK" sign_component "$SPARKLE_FRAMEWORK"
codesign --force --timestamp --options runtime \ codesign --force --timestamp --options runtime --keychain "$SIGNING_KEYCHAIN" \
--entitlements meetingnotes/meetingnotes.entitlements \ --entitlements meetingnotes/meetingnotes.entitlements \
--sign "$SIGNING_IDENTITY" \ --sign "$SIGNING_IDENTITY" \
"$APP_PATH" "$APP_PATH"
@@ -116,7 +118,7 @@ if [[ -z "$GENERATE_APPCAST" ]]; then
exit 1 exit 1
fi 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" \ printf '%s' "$SPARKLE_PRIVATE_KEY" | "$GENERATE_APPCAST" "$RELEASE_DIR" \
--ed-key-file - \ --ed-key-file - \
--download-url-prefix "$DOWNLOAD_URL" \ --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" grep -q 'sparkle:edSignature=' "$RELEASE_DIR/appcast.xml"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then 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" "$VERSION" >> "$GITHUB_STEP_SUMMARY"
fi 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)
+5 -1
View File
@@ -16,7 +16,7 @@ with socket.socket() as listener:
port = listener.getsockname()[1] port = listener.getsockname()[1]
with tempfile.TemporaryFile() as log: with tempfile.TemporaryFile() as log:
process = subprocess.Popen( process = subprocess.Popen(
[str(app), "-muteDeckAPIEnabled", "YES", "-muteDeckAPIPort", str(port)], [str(app), "-muteDeckAPIEnabled", "YES", "-muteDeckAPIPort", str(port), "-hasCompletedOnboarding", "YES", "-hasAcceptedTerms", "YES", "-SUEnableAutomaticChecks", "NO"],
stdout=log, stderr=subprocess.STDOUT, stdout=log, stderr=subprocess.STDOUT,
) )
try: try:
@@ -39,6 +39,10 @@ with tempfile.TemporaryFile() as log:
else: else:
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:
log.seek(0)
print(log.read().decode(errors="replace")[-8000:], file=sys.stderr)
raise
finally: finally:
if process.poll() is None: if process.poll() is None:
process.terminate() process.terminate()
+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)