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)