Skip to content

Commit e2238da

Browse files
Add signed release pipeline with GitHub Actions
Entitlements for app sandbox, file access, and networking. GitHub Actions workflow builds a universal binary, signs with Developer ID, notarizes with Apple, and publishes DMG + ZIP to GitHub Releases on version tags.
1 parent 8bf3f77 commit e2238da

File tree

6 files changed

+515
-3
lines changed

6 files changed

+515
-3
lines changed

.github/workflows/release.yml

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build-and-release:
13+
name: Build, sign, notarize, and release
14+
runs-on: macos-15
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
19+
- name: Extract version from tag
20+
id: version
21+
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
22+
23+
- name: Import signing certificate
24+
env:
25+
CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}
26+
CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }}
27+
run: |
28+
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
29+
KEYCHAIN_PASSWORD="$(openssl rand -base64 32)"
30+
echo "::add-mask::$KEYCHAIN_PASSWORD"
31+
32+
# Create temporary keychain
33+
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
34+
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
35+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
36+
37+
# Import certificate
38+
CERT_PATH="$RUNNER_TEMP/certificate.p12"
39+
echo "$CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH"
40+
security import "$CERT_PATH" \
41+
-k "$KEYCHAIN_PATH" \
42+
-P "$CERTIFICATE_PASSWORD" \
43+
-T /usr/bin/codesign
44+
rm -f "$CERT_PATH"
45+
46+
# Allow codesign to access the keychain
47+
security set-key-partition-list \
48+
-S apple-tool:,apple:,codesign: \
49+
-s -k "$KEYCHAIN_PASSWORD" \
50+
"$KEYCHAIN_PATH"
51+
52+
# Add to search list
53+
EXISTING_KEYCHAINS="$(security list-keychains -d user | tr -d '"' | tr '\n' ' ')"
54+
security list-keychains -d user -s "$KEYCHAIN_PATH" $EXISTING_KEYCHAINS
55+
56+
- name: Build
57+
run: swift build -c release --arch arm64 --arch x86_64
58+
59+
- name: Prepare app bundle
60+
env:
61+
VERSION: ${{ steps.version.outputs.version }}
62+
run: |
63+
APP_BUNDLE=".build/Reading List.app"
64+
PLIST="$APP_BUNDLE/Contents/Info.plist"
65+
66+
# Set version
67+
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST"
68+
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $(date +%Y%m%d%H%M%S)" "$PLIST"
69+
70+
- name: Sign app
71+
env:
72+
DEVELOPER_ID_APPLICATION: ${{ secrets.DEVELOPER_ID_APPLICATION }}
73+
run: |
74+
codesign --force \
75+
--sign "$DEVELOPER_ID_APPLICATION" \
76+
--entitlements Entitlements.plist \
77+
--options runtime \
78+
--timestamp \
79+
".build/Reading List.app"
80+
81+
codesign --verify --strict ".build/Reading List.app"
82+
83+
- name: Notarize app
84+
env:
85+
APPLE_ID: ${{ secrets.APPLE_ID }}
86+
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
87+
TEAM_ID: ${{ secrets.TEAM_ID }}
88+
run: |
89+
ditto -c -k --keepParent ".build/Reading List.app" "ReadingList-notarize.zip"
90+
91+
xcrun notarytool submit "ReadingList-notarize.zip" \
92+
--apple-id "$APPLE_ID" \
93+
--password "$APPLE_ID_PASSWORD" \
94+
--team-id "$TEAM_ID" \
95+
--wait
96+
97+
xcrun stapler staple ".build/Reading List.app"
98+
rm -f "ReadingList-notarize.zip"
99+
100+
- name: Create DMG
101+
env:
102+
VERSION: ${{ steps.version.outputs.version }}
103+
DEVELOPER_ID_APPLICATION: ${{ secrets.DEVELOPER_ID_APPLICATION }}
104+
run: |
105+
mkdir -p dmg-staging
106+
cp -R ".build/Reading List.app" dmg-staging/
107+
ln -s /Applications dmg-staging/Applications
108+
109+
hdiutil create \
110+
-volname "Reading List" \
111+
-srcfolder dmg-staging \
112+
-ov \
113+
-format UDZO \
114+
"Reading-List-${VERSION}.dmg"
115+
116+
codesign --force --sign "$DEVELOPER_ID_APPLICATION" --timestamp "Reading-List-${VERSION}.dmg"
117+
rm -rf dmg-staging
118+
119+
- name: Create ZIP
120+
env:
121+
VERSION: ${{ steps.version.outputs.version }}
122+
run: |
123+
ditto -c -k --keepParent ".build/Reading List.app" "Reading-List-${VERSION}.zip"
124+
125+
- name: Create GitHub Release
126+
env:
127+
GH_TOKEN: ${{ github.token }}
128+
VERSION: ${{ steps.version.outputs.version }}
129+
run: |
130+
gh release create "$GITHUB_REF_NAME" \
131+
--title "Reading List v${VERSION}" \
132+
--generate-notes \
133+
"Reading-List-${VERSION}.dmg#Reading List ${VERSION} (DMG)" \
134+
"Reading-List-${VERSION}.zip#Reading List ${VERSION} (ZIP)"
135+
136+
- name: Cleanup keychain
137+
if: always()
138+
run: |
139+
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
140+
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true

CLAUDE.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Reading List is a native macOS app (Swift 6, SwiftUI) for browsing Safari's Reading List. It reads and writes `~/Library/Safari/Bookmarks.plist` directly—there is no public Apple API for this. The app is built with Swift Package Manager (not Xcode project files).
8+
9+
## Build & Run
10+
11+
```bash
12+
swift build # compile
13+
swift run "Reading List" # run normally
14+
swift run "Reading List" --demo-data # run with fake data (no plist access)
15+
```
16+
17+
The product name is `"Reading List"` (with a space); the SPM target is `ReadLater`.
18+
19+
## Architecture
20+
21+
All source is in `Sources/ReadLater/`. Key layers:
22+
23+
- **SafariReadingListService** — reads/writes Safari's `Bookmarks.plist` (binary plist parsing, no Apple API). Handles fetch, mark-read, mark-unread. This is a `Sendable` struct; heavy work runs on detached tasks.
24+
- **BookmarkAccessManager** — manages App Sandbox security-scoped bookmark access to the plist. Uses `NSOpenPanel` file picker on first launch; persists access via `UserDefaults` bookmark data.
25+
- **ReadingListViewModel**`@MainActor ObservableObject` driving the UI. Holds all items, computes filtered/displayed items by folder selection, search query, and read-status filter.
26+
- **SmartFolderStore** — persists custom smart folders to `~/Library/Application Support/ReadLater/custom-smart-folders.json`. Seeds default folders (Recently Added, Videos, PDFs) on first run.
27+
- **SmartFolders** — defines `SmartFolder`, `CustomSmartFolder`, `FolderSelection`, and `AddedDateFilter`. Smart folders match items by hostname set, keyword list, and date filter.
28+
- **ContentView** — three-column `NavigationSplitView`: sidebar (smart lists + domain folders), item list (with pagination at 250-item pages), and web preview pane.
29+
- **FaviconStore** — uses Nuke/NukeUI for favicon loading via Google's favicon service, with a 100 MB disk cache.
30+
31+
## Key Conventions
32+
33+
- Swift 6 strict concurrency; `@MainActor` on view models and stores, `Sendable` on services and models.
34+
- macOS 13+ minimum deployment target.
35+
- Demo mode (`--demo-data` flag or `READING_LIST_DEMO=1` env var) uses `DemoReadingListData` — no file system access.
36+
- Read-status writes go directly to Safari's `Bookmarks.plist` (atomic write). This is the only write operation.
37+
- `ReadingListItem.id` is a composite of URL + dateAdded timestamp to handle duplicate URLs.

Entitlements.plist

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.security.app-sandbox</key>
6+
<true/>
7+
<key>com.apple.security.files.user-selected.read-write</key>
8+
<true/>
9+
<key>com.apple.security.network.client</key>
10+
<true/>
11+
</dict>
12+
</plist>

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ Safari makes it very easy to save links for later on macOS and iOS, but there is
88

99
This project is meant to be that missing macOS companion: a focused app for rediscovering your saved Safari Reading List items.
1010

11+
## Download
12+
13+
Download the latest version from the [GitHub Releases](../../releases/latest) page.
14+
15+
- **Reading-List-x.x.x.dmg** — open the DMG and drag "Reading List" to your Applications folder.
16+
- **Reading-List-x.x.x.zip** — unzip and move "Reading List.app" to your Applications folder.
17+
18+
The app is signed and notarized with Apple, so macOS will allow it to run without security warnings.
19+
20+
**Requirements:** macOS 13 (Ventura) or later.
21+
22+
On first launch, the app asks you to select your `~/Library/Safari/Bookmarks.plist` file. You only need to do this once.
23+
1124
## Design inspiration
1225

1326
The UI is partly inspired by NetNewsWire and follows traditional macOS design patterns with native controls.
@@ -64,7 +77,7 @@ swift run "Reading List" --demo-data
6477

6578
In demo mode, the app does not read from or write to Safari's `Bookmarks.plist`.
6679

67-
## Build and run
80+
## Build from source
6881

6982
1. Open this folder in Xcode and run the `Reading List` executable product.
7083
2. Or run from Terminal:
@@ -81,8 +94,7 @@ sudo xcodebuild -license
8194

8295
## Project status
8396

84-
This is currently a fast-moving source project.
85-
I may publish a packaged version later (for example App Store or direct download), but for now the intended way to use it is to build from source yourself.
97+
This app is under active development and should be considered beta software.
8698

8799
## Contributions and support
88100

RELEASING.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Releasing Reading List
2+
3+
## One-time setup
4+
5+
### 1. Create a Developer ID Application certificate
6+
7+
You need a **Developer ID Application** certificate (not a regular development cert) to sign apps for distribution outside the Mac App Store.
8+
9+
1. Open **Keychain Access** on your Mac.
10+
2. Go to **Keychain Access > Certificate Assistant > Request a Certificate from a Certificate Authority**.
11+
3. Fill in your email, select "Saved to disk", and save the CSR file.
12+
4. Go to [Apple Developer > Certificates](https://developer.apple.com/account/resources/certificates/list).
13+
5. Click **+**, select **Developer ID Application**, upload your CSR.
14+
6. Download and double-click the certificate to install it in your keychain.
15+
16+
### 2. Export the certificate as .p12
17+
18+
1. Open **Keychain Access**.
19+
2. Find your **Developer ID Application** certificate (under "My Certificates").
20+
3. Right-click > **Export** as `.p12` format. Set a strong password.
21+
4. Base64-encode it: `base64 -i certificate.p12 | pbcopy`
22+
23+
### 3. Create an app-specific password
24+
25+
1. Go to [appleid.apple.com](https://appleid.apple.com) > Sign-In and Security > App-Specific Passwords.
26+
2. Generate one named "Reading List Notarization".
27+
28+
### 4. Find your Team ID
29+
30+
1. Go to [Apple Developer > Membership Details](https://developer.apple.com/account#MembershipDetailsCard).
31+
2. Copy your **Team ID** (10-character alphanumeric string).
32+
33+
### 5. Find your signing identity name
34+
35+
Run this in Terminal:
36+
37+
```bash
38+
security find-identity -v -p codesigning | grep "Developer ID Application"
39+
```
40+
41+
Copy the full identity string, e.g.: `Developer ID Application: Your Name (TEAMID123)`
42+
43+
### 6. Add GitHub repository secrets
44+
45+
Go to your repo's **Settings > Secrets and variables > Actions** and add:
46+
47+
| Secret | Value |
48+
|---|---|
49+
| `DEVELOPER_ID_CERTIFICATE_BASE64` | Base64-encoded .p12 file contents |
50+
| `DEVELOPER_ID_CERTIFICATE_PASSWORD` | Password you set when exporting the .p12 |
51+
| `DEVELOPER_ID_APPLICATION` | Full signing identity, e.g. `Developer ID Application: Your Name (TEAM123)` |
52+
| `APPLE_ID` | Your Apple ID email |
53+
| `APPLE_ID_PASSWORD` | App-specific password from step 3 |
54+
| `TEAM_ID` | Your 10-character Team ID |
55+
56+
## Creating a release
57+
58+
Tag a new version and push:
59+
60+
```bash
61+
git tag v1.0.0
62+
git push origin v1.0.0
63+
```
64+
65+
This triggers the GitHub Actions workflow which will:
66+
67+
1. Build a universal binary (ARM + Intel)
68+
2. Sign with your Developer ID certificate
69+
3. Notarize with Apple
70+
4. Create a DMG and ZIP
71+
5. Publish a GitHub Release with both artifacts
72+
73+
## Building locally (optional)
74+
75+
To build a signed and notarized release on your own machine:
76+
77+
```bash
78+
export DEVELOPER_ID_APPLICATION="Developer ID Application: Your Name (TEAM123)"
79+
export APPLE_ID="your@email.com"
80+
export APPLE_ID_PASSWORD="xxxx-xxxx-xxxx-xxxx"
81+
export TEAM_ID="TEAM123"
82+
83+
./scripts/build-release.sh
84+
```
85+
86+
Artifacts are written to `.build/Reading-List-<version>.dmg` and `.build/Reading-List-<version>.zip`.
87+
88+
## What users get
89+
90+
Users download the DMG, open it, and drag "Reading List" to their Applications folder. Because the app is signed and notarized, macOS Gatekeeper will allow it to run without security warnings.
91+
92+
On first launch, the app asks the user to select their `~/Library/Safari/Bookmarks.plist` file via a standard file picker (required for sandbox access).

0 commit comments

Comments
 (0)