diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..96bda74
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -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
diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj
index d9e752b..1132755 100644
--- a/Meetingnotes.xcodeproj/project.pbxproj
+++ b/Meetingnotes.xcodeproj/project.pbxproj
@@ -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;
diff --git a/README.md b/README.md
index 60b0e08..dbb5cb3 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/meetingnotes/Info.plist b/meetingnotes/Info.plist
index 650169b..35e0fea 100644
--- a/meetingnotes/Info.plist
+++ b/meetingnotes/Info.plist
@@ -14,7 +14,7 @@
NSMicrophoneUsageDescription
Meetingnotes needs access to your microphone for transcription.
SUFeedURL
- https://github.com/superdooper86/meetingnotes/releases/latest/download/appcast.xml
+ https://git.jamesbone.net/coder/meetingnotes/releases/download/latest/appcast.xml
SUPublicEDKey
9ZuN9G9ERB3Qoyyd/4FsF+6LMUv5jzAGP26OXAHBiW0=
SUEnableAutomaticChecks
diff --git a/meetingnotes/Views/SettingsView.swift b/meetingnotes/Views/SettingsView.swift
index c8ae3a0..ee4886a 100644
--- a/meetingnotes/Views/SettingsView.swift
+++ b/meetingnotes/Views/SettingsView.swift
@@ -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
diff --git a/scripts/package_release.sh b/scripts/package_release.sh
index 3e3451b..717a7c6 100755
--- a/scripts/package_release.sh
+++ b/scripts/package_release.sh
@@ -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
diff --git a/scripts/publish_gitea_release.py b/scripts/publish_gitea_release.py
new file mode 100644
index 0000000..a81e48b
--- /dev/null
+++ b/scripts/publish_gitea_release.py
@@ -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)
diff --git a/scripts/smoke_test_api.py b/scripts/smoke_test_api.py
index 6ec44c4..ec072c5 100644
--- a/scripts/smoke_test_api.py
+++ b/scripts/smoke_test_api.py
@@ -16,7 +16,7 @@ with socket.socket() as listener:
port = listener.getsockname()[1]
with tempfile.TemporaryFile() as log:
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,
)
try:
@@ -39,6 +39,10 @@ with tempfile.TemporaryFile() as log:
else:
raise RuntimeError("Recording status allowed an unauthenticated request")
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:
if process.poll() is None:
process.terminate()
diff --git a/scripts/with_signing_identity.py b/scripts/with_signing_identity.py
new file mode 100644
index 0000000..4a3a183
--- /dev/null
+++ b/scripts/with_signing_identity.py
@@ -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)