diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f2af156 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Release macOS app + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Sign, notarize, and publish + runs-on: macos-15 + timeout-minutes: 45 + env: + LOCI_X_CLIENT_ID: ${{ secrets.LOCI_X_CLIENT_ID }} + NOTARY_PROFILE: loci-notary + REQUIRE_NOTARIZATION: "1" + GH_TOKEN: ${{ github.token }} + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Validate release tag + run: | + VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Support/Loci.Info.plist) + test "${GITHUB_REF_NAME}" = "v${VERSION}" + + - name: Import Developer ID certificate + env: + DEVELOPER_ID_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12_BASE64 }} + DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + run: | + : "${DEVELOPER_ID_P12_BASE64:?Configure the DEVELOPER_ID_P12_BASE64 Actions secret}" + : "${DEVELOPER_ID_P12_PASSWORD:?Configure the DEVELOPER_ID_P12_PASSWORD Actions secret}" + KEYCHAIN_PASSWORD="$(uuidgen)" + CERTIFICATE_PATH="${RUNNER_TEMP}/developer-id.p12" + printf '%s' "${DEVELOPER_ID_P12_BASE64}" | /usr/bin/base64 -D > "${CERTIFICATE_PATH}" + security create-keychain -p "${KEYCHAIN_PASSWORD}" release.keychain-db + security set-keychain-settings -lut 21600 release.keychain-db + security unlock-keychain -p "${KEYCHAIN_PASSWORD}" release.keychain-db + security import "${CERTIFICATE_PATH}" -P "${DEVELOPER_ID_P12_PASSWORD}" -A -t cert -f pkcs12 -k release.keychain-db + security list-keychain -d user -s release.keychain-db + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${KEYCHAIN_PASSWORD}" release.keychain-db + + - name: Configure Apple notarization + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + : "${APPLE_ID:?Configure the APPLE_ID Actions secret}" + : "${APPLE_APP_SPECIFIC_PASSWORD:?Configure the APPLE_APP_SPECIFIC_PASSWORD Actions secret}" + : "${APPLE_TEAM_ID:?Configure the APPLE_TEAM_ID Actions secret}" + xcrun notarytool store-credentials "${NOTARY_PROFILE}" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_APP_SPECIFIC_PASSWORD}" + + - name: Build, sign, notarize, and package + run: scripts/package-beta.sh + + - name: Verify distributable artifacts + run: | + VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Support/Loci.Info.plist) + BUILD=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' Support/Loci.Info.plist) + DMG="dist/Loci-${VERSION}-b${BUILD}.dmg" + ZIP="dist/Loci-${VERSION}-b${BUILD}.zip" + ditto -x -k "${ZIP}" "${RUNNER_TEMP}/loci-zip" + ARCHS=" $(lipo -archs "${RUNNER_TEMP}/loci-zip/Loci.app/Contents/MacOS/Loci") " + [[ "${ARCHS}" == *" arm64 "* ]] + [[ "${ARCHS}" == *" x86_64 "* ]] + codesign --verify --deep --strict --verbose=2 "${RUNNER_TEMP}/loci-zip/Loci.app" + xcrun stapler validate "${RUNNER_TEMP}/loci-zip/Loci.app" + spctl -a -vv -t exec "${RUNNER_TEMP}/loci-zip/Loci.app" + hdiutil verify "${DMG}" + codesign --verify --verbose=2 "${DMG}" + xcrun stapler validate "${DMG}" + spctl -a -vv -t open --context context:primary-signature "${DMG}" + unzip -t "${ZIP}" + (cd dist && shasum -a 256 -c SHA256SUMS.txt) + + - name: Publish GitHub release + run: | + VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Support/Loci.Info.plist) + BUILD=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' Support/Loci.Info.plist) + gh release create "${GITHUB_REF_NAME}" \ + "dist/Loci-${VERSION}-b${BUILD}.dmg" \ + "dist/Loci-${VERSION}-b${BUILD}.zip" \ + "dist/SHA256SUMS.txt" \ + --verify-tag \ + --generate-notes \ + --title "Loci ${VERSION}" diff --git a/README.md b/README.md index eb3b56f..1219700 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,16 @@ Loci keeps references visible and connected instead of scattering them across br Optional integrations have their own requirements. See [Integrations](docs/INTEGRATIONS.md). +## Install Loci + +Signed and notarized builds are published on the [GitHub Releases page](https://github.com/PermutationResearch/Loci/releases). Download the DMG, open it, and drag **Loci** to the **Applications** shortcut. Release assets include `SHA256SUMS.txt` so you can verify the download with: + +```sh +shasum -a 256 -c SHA256SUMS.txt +``` + +If the Releases page has no DMG yet, a public binary has not been published; use the source build below in the meantime. + ## Quick Start ```sh diff --git a/Sources/Loci/DocumentExtractor.swift b/Sources/Loci/DocumentExtractor.swift index 1f15869..31a1b56 100644 --- a/Sources/Loci/DocumentExtractor.swift +++ b/Sources/Loci/DocumentExtractor.swift @@ -124,6 +124,10 @@ enum DocumentExtractor { return URL(fileURLWithPath: path) } + if let bundled = Bundle.main.url(forResource: "loci-extract", withExtension: "py", subdirectory: "scripts") { + return bundled + } + if let bundled = Bundle.module.url(forResource: "loci-extract", withExtension: "py", subdirectory: "scripts") { return bundled } diff --git a/Sources/Loci/LociApp.swift b/Sources/Loci/LociApp.swift index 346df35..b8bd7b0 100644 --- a/Sources/Loci/LociApp.swift +++ b/Sources/Loci/LociApp.swift @@ -326,7 +326,9 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, } private func configureAppIcon() { - if let iconURL = Bundle.module.url(forResource: "AppIcon", withExtension: "png"), + let iconURL = Bundle.main.url(forResource: "AppIcon", withExtension: "png") + ?? Bundle.module.url(forResource: "AppIcon", withExtension: "png") + if let iconURL, let icon = NSImage(contentsOf: iconURL) { NSApp.applicationIconImage = icon } diff --git a/Sources/Loci/LociTelemetry.swift b/Sources/Loci/LociTelemetry.swift index 90bee6f..5cf6885 100644 --- a/Sources/Loci/LociTelemetry.swift +++ b/Sources/Loci/LociTelemetry.swift @@ -66,7 +66,7 @@ enum LociTelemetry { "x_bookmark_count" ] - /// Enabled by default; users opt out from Settings → Privacy. An explicit + /// Disabled by default; users opt in from Settings → Privacy. An explicit /// choice under either the current or legacy key always wins. static var isEnabled: Bool { get { @@ -76,7 +76,7 @@ enum LociTelemetry { if UserDefaults.standard.object(forKey: legacyEnabledKey) != nil { return UserDefaults.standard.bool(forKey: legacyEnabledKey) } - return true + return false } set { UserDefaults.standard.set(newValue, forKey: enabledKey) } } diff --git a/Sources/Loci/SettingsView.swift b/Sources/Loci/SettingsView.swift index f46c658..f7a38e7 100644 --- a/Sources/Loci/SettingsView.swift +++ b/Sources/Loci/SettingsView.swift @@ -44,7 +44,7 @@ struct SettingsView: View { @AppStorage("LociAutoCompile") private var autoCompile = false @AppStorage("LociVaultPath") private var vaultPath = "" @AppStorage("LociXRedirectMode") private var xRedirectModeRaw = XOAuthRedirectMode.recommended.rawValue - @AppStorage(LociTelemetry.enabledKey) private var telemetryEnabled = true + @AppStorage(LociTelemetry.enabledKey) private var telemetryEnabled = false @AppStorage(LociTelemetry.endpointKey) private var telemetryEndpoint = "" @StateObject private var xOAuth = XOAuthManager.shared diff --git a/Tests/LociTests/TelemetryTests.swift b/Tests/LociTests/TelemetryTests.swift index d9ff3e2..6d51304 100644 --- a/Tests/LociTests/TelemetryTests.swift +++ b/Tests/LociTests/TelemetryTests.swift @@ -4,14 +4,14 @@ import Testing @Suite("Telemetry") struct TelemetryTests { - @Test("Telemetry defaults on, honors opt-out, and allowlists properties") + @Test("Telemetry defaults off, honors opt-in, and allowlists properties") func telemetryDefaultAndAllowlist() async throws { LociTelemetry.clearLocalQueue() LociTelemetry.endpointString = "" UserDefaults.standard.removeObject(forKey: LociTelemetry.enabledKey) UserDefaults.standard.removeObject(forKey: "AtlasTelemetryEnabled") - #expect(LociTelemetry.isEnabled) + #expect(!LociTelemetry.isEnabled) LociTelemetry.isEnabled = false diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 369e5d0..fb70b3e 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -5,6 +5,13 @@ Use this checklist before calling a build market-ready. A local ad-hoc DMG is us ## P0: Release Blockers - Install a valid Apple Developer ID Application certificate on the release Mac. +- For GitHub releases, configure these Actions secrets: + - `DEVELOPER_ID_P12_BASE64` + - `DEVELOPER_ID_P12_PASSWORD` + - `APPLE_ID` + - `APPLE_APP_SPECIFIC_PASSWORD` + - `APPLE_TEAM_ID` + - `LOCI_X_CLIENT_ID` (optional) - Package without ad-hoc signing: `scripts/package-beta.sh` - Set the bundled X OAuth client ID for release builds: @@ -16,6 +23,9 @@ Use this checklist before calling a build market-ready. A local ad-hoc DMG is us - Verify the DMG and ZIP: `hdiutil verify dist/Loci-0.1-b1.dmg` `unzip -t dist/Loci-0.1-b1.zip` + `(cd dist && shasum -a 256 -c SHA256SUMS.txt)` +- Confirm the packaged executable contains both supported architectures: + `lipo -archs dist/Loci.app/Contents/MacOS/Loci` - Revoke and regenerate any X tokens that were pasted into chat, screenshots, logs, or local notes. - Confirm the committed license is still the intended license for the release. - Publish `docs/TELEMETRY_AND_PRIVACY.md` with the release. @@ -65,3 +75,11 @@ Use this checklist before calling a build market-ready. A local ad-hoc DMG is us ## Ship Decision Loci is market-ready only when the signed/notarized package, X sync, large-library performance, first-run onboarding, and privacy/security checks all pass on a clean Mac. + +## Publish Through GitHub Actions + +1. Update `CFBundleShortVersionString` and increment `CFBundleVersion` in `Support/Loci.Info.plist`. +2. Merge the version change to `main` and confirm CI passes. +3. Create and push a matching tag, for example `v0.1` for version `0.1`. +4. The `Release macOS app` workflow builds a universal app, signs and notarizes it, staples the app and DMG, verifies Gatekeeper acceptance, generates checksums, and publishes the GitHub Release. +5. Download the release DMG on a different Mac and complete a clean install before announcing the release. diff --git a/scripts/package-beta.sh b/scripts/package-beta.sh index ea0921a..b2fdc93 100755 --- a/scripts/package-beta.sh +++ b/scripts/package-beta.sh @@ -12,14 +12,23 @@ VERSION="${VERSION:-$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionStr BUILD="${BUILD:-$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "${INFO_TEMPLATE}")}" DMG_PATH="${DIST_DIR}/${APP_NAME}-${VERSION}-b${BUILD}.dmg" ZIP_PATH="${DIST_DIR}/${APP_NAME}-${VERSION}-b${BUILD}.zip" +CHECKSUM_PATH="${DIST_DIR}/SHA256SUMS.txt" FINAL_APP_DIR="${DIST_DIR}/${APP_NAME}.app" ALLOW_ADHOC="${ALLOW_ADHOC:-0}" KEEP_APP="${KEEP_APP:-0}" REQUIRE_NOTARIZATION="${REQUIRE_NOTARIZATION:-0}" NOTARY_PROFILE="${NOTARY_PROFILE:-}" +if [[ -z "${ARCHITECTURES:-}" ]]; then + if [[ "${ALLOW_ADHOC}" == "1" ]]; then + ARCHITECTURES="$(uname -m)" + else + ARCHITECTURES="arm64 x86_64" + fi +fi WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/loci-package.XXXXXX")" APP_DIR="${WORK_DIR}/${APP_NAME}.app" +DMG_STAGE_DIR="${WORK_DIR}/dmg" CONTENTS_DIR="${APP_DIR}/Contents" MACOS_DIR="${CONTENTS_DIR}/MacOS" RESOURCES_DIR="${CONTENTS_DIR}/Resources" @@ -93,7 +102,7 @@ sign_dmg() { codesign --verify --verbose=2 "${DMG_PATH}" } -notarize_if_requested() { +validate_notarization_configuration() { if [[ -z "${NOTARY_PROFILE}" ]]; then if [[ "${REQUIRE_NOTARIZATION}" == "1" ]]; then cat >&2 <<'EOF' @@ -110,8 +119,22 @@ EOF exit 3 fi echo "NOTARY_PROFILE not set; skipping notarization." - return 0 fi +} + +notarize_app_if_requested() { + [[ -n "${NOTARY_PROFILE}" ]] || return 0 + + local submission_zip="${WORK_DIR}/${APP_NAME}-notarization.zip" + echo "Submitting ${APP_NAME}.app for notarization" + ditto -c -k --norsrc --keepParent "${APP_DIR}" "${submission_zip}" + xcrun notarytool submit "${submission_zip}" --keychain-profile "${NOTARY_PROFILE}" --wait + xcrun stapler staple "${APP_DIR}" + xcrun stapler validate "${APP_DIR}" +} + +notarize_dmg_if_requested() { + [[ -n "${NOTARY_PROFILE}" ]] || return 0 echo "Submitting ${DMG_PATH} for notarization" xcrun notarytool submit "${DMG_PATH}" --keychain-profile "${NOTARY_PROFILE}" --wait @@ -120,16 +143,23 @@ EOF } echo "Packaging ${APP_NAME} ${VERSION} build ${BUILD}" +validate_notarization_configuration -rm -rf "${FINAL_APP_DIR}" "${DMG_PATH}" "${ZIP_PATH}" +rm -rf "${FINAL_APP_DIR}" "${DMG_PATH}" "${ZIP_PATH}" "${CHECKSUM_PATH}" mkdir -p "${DIST_DIR}" "${MACOS_DIR}" "${RESOURCES_DIR}" -swift build -c "${CONFIGURATION}" --package-path "${ROOT_DIR}" +BUILD_ARGS=(-c "${CONFIGURATION}" --package-path "${ROOT_DIR}") +for architecture in ${ARCHITECTURES}; do + BUILD_ARGS+=(--arch "${architecture}") +done +swift build "${BUILD_ARGS[@]}" +BUILD_BIN_DIR="$(swift build "${BUILD_ARGS[@]}" --show-bin-path)" -cp "${ROOT_DIR}/.build/${CONFIGURATION}/${EXECUTABLE_NAME}" "${MACOS_DIR}/${EXECUTABLE_NAME}" +cp "${BUILD_BIN_DIR}/${EXECUTABLE_NAME}" "${MACOS_DIR}/${EXECUTABLE_NAME}" cp "${INFO_TEMPLATE}" "${INFO_PLIST}" cp "${APP_ICON_ICNS}" "${RESOURCES_DIR}/Loci.icns" cp "${ROOT_DIR}/Sources/Loci/Resources/AppIcon.png" "${RESOURCES_DIR}/AppIcon.png" +ditto --norsrc "${ROOT_DIR}/Sources/Loci/Resources/scripts" "${RESOURCES_DIR}/scripts" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${VERSION}" "${INFO_PLIST}" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${BUILD}" "${INFO_PLIST}" @@ -143,17 +173,25 @@ fi strip_xattrs "${APP_DIR}" SIGNING_IDENTITY="$(resolve_signing_identity)" sign_app "${SIGNING_IDENTITY}" +notarize_app_if_requested ( cd "${WORK_DIR}" ditto -c -k --norsrc --keepParent "${APP_NAME}.app" "${ZIP_PATH}" ) -hdiutil create -volname "${APP_NAME}" -srcfolder "${APP_DIR}" -ov -format UDZO "${DMG_PATH}" +mkdir -p "${DMG_STAGE_DIR}" +ditto --norsrc "${APP_DIR}" "${DMG_STAGE_DIR}/${APP_NAME}.app" +ln -s /Applications "${DMG_STAGE_DIR}/Applications" +hdiutil create -volname "${APP_NAME}" -srcfolder "${DMG_STAGE_DIR}" -ov -format UDZO "${DMG_PATH}" sign_dmg "${SIGNING_IDENTITY}" -notarize_if_requested +notarize_dmg_if_requested strip_xattrs "${DMG_PATH}" "${ZIP_PATH}" +( + cd "${DIST_DIR}" + shasum -a 256 "$(basename "${DMG_PATH}")" "$(basename "${ZIP_PATH}")" > "$(basename "${CHECKSUM_PATH}")" +) if [[ "${KEEP_APP}" == "1" ]]; then ditto --norsrc "${APP_DIR}" "${FINAL_APP_DIR}" @@ -171,3 +209,4 @@ fi echo "ZIP ${ZIP_PATH}" echo "DMG ${DMG_PATH}" +echo "SHA256 ${CHECKSUM_PATH}"