Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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}"
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Sources/Loci/DocumentExtractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/Loci/LociApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Loci/LociTelemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) }
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Loci/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Tests/LociTests/TelemetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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.
53 changes: 46 additions & 7 deletions scripts/package-beta.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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}"
Expand All @@ -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}"
Expand All @@ -171,3 +209,4 @@ fi

echo "ZIP ${ZIP_PATH}"
echo "DMG ${DMG_PATH}"
echo "SHA256 ${CHECKSUM_PATH}"