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
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
- main
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand All @@ -17,7 +20,9 @@ jobs:

steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false

- name: Show Xcode version
run: xcodebuild -version
Expand Down
130 changes: 78 additions & 52 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,35 @@ on:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: Existing version tag to release (for example, v1.2.3)
required: true
type: string

permissions:
contents: write
contents: read

concurrency:
group: release-${{ github.ref }}
group: release
cancel-in-progress: false

env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}

jobs:
build-and-release:
permissions:
contents: write
runs-on: macos-26
timeout-minutes: 30
env:
APP_PATH: /tmp/octodot-export/Octodot.app
XCARCHIVE_PATH: /tmp/Octodot.xcarchive
EXPORT_PATH: /tmp/octodot-export
EXPORT_OPTIONS_PLIST: /tmp/OctodotExportOptions.plist
ARCHIVE_PATH: /tmp/Octodot-${{ github.ref_name }}-unsigned.zip
FINAL_ARCHIVE_PATH: Octodot-${{ github.ref_name }}-macos.zip
ARCHIVE_PATH: /tmp/Octodot-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}-unsigned.zip
FINAL_ARCHIVE_PATH: Octodot-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}-macos.zip
KEYCHAIN_PATH: /tmp/octodot-build.keychain-db
KEYCHAIN_PROFILE: octodot-notary

Expand All @@ -32,38 +42,36 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ env.RELEASE_TAG }}

- name: Verify release ref is main
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
default_branch="${{ github.event.repository.default_branch }}"
git fetch --force origin "$default_branch:refs/remotes/origin/$default_branch"
git fetch --force origin "$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH"

if [ "$GITHUB_EVENT_NAME" = "push" ]; then
if [ "$GITHUB_REF_TYPE" != "tag" ]; then
echo "::error::Release pushes must be tag refs"
exit 1
fi
scripts/validate_release_tag.sh "$RELEASE_TAG"

if ! git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG^{commit}" >/dev/null; then
echo "::error::Release tag $RELEASE_TAG does not exist"
exit 1
fi

tag_commit="$(git rev-list -n 1 "$GITHUB_REF_NAME")"
main_commit="$(git rev-parse "origin/$default_branch")"
tag_commit="$(git rev-list -n 1 "$RELEASE_TAG")"
main_commit="$(git rev-parse "origin/$DEFAULT_BRANCH")"

if [ "$tag_commit" != "$main_commit" ]; then
echo "::error::Release tag $GITHUB_REF_NAME points at $tag_commit, not origin/$default_branch $main_commit"
exit 1
fi
elif [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
if [ "$GITHUB_REF_NAME" != "$default_branch" ]; then
echo "::error::Manual releases must be dispatched from $default_branch"
exit 1
fi
if [ "$tag_commit" != "$main_commit" ]; then
echo "::error::Release tag $RELEASE_TAG points at $tag_commit, not origin/$DEFAULT_BRANCH $main_commit"
exit 1
fi

- name: Show Xcode version
run: xcodebuild -version

- name: Resolve release version
run: |
echo "RELEASE_MARKETING_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
echo "RELEASE_MARKETING_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV"
echo "RELEASE_BUILD_NUMBER=${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV"

- name: Run test suite
Expand Down Expand Up @@ -159,6 +167,24 @@ jobs:
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
codesign --display --verbose=4 "$APP_PATH"

info_plist="$APP_PATH/Contents/Info.plist"
actual_bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$info_plist")"
actual_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$info_plist")"
actual_build="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$info_plist")"

if [ "$actual_bundle_id" != "com.octodot.app" ]; then
echo "::error::Unexpected bundle identifier: $actual_bundle_id"
exit 1
fi
if [ "$actual_version" != "$RELEASE_MARKETING_VERSION" ]; then
echo "::error::Expected version $RELEASE_MARKETING_VERSION, found $actual_version"
exit 1
fi
if [ "$actual_build" != "$RELEASE_BUILD_NUMBER" ]; then
echo "::error::Expected build $RELEASE_BUILD_NUMBER, found $actual_build"
exit 1
fi

- name: Store notarization credentials
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
Expand All @@ -182,32 +208,32 @@ jobs:

- name: Submit for notarization
run: |
set +e
xcrun notarytool submit "$ARCHIVE_PATH" \
--keychain-profile "$KEYCHAIN_PROFILE" \
--keychain "$KEYCHAIN_PATH" \
--wait \
--output-format json | tee /tmp/notary-submit-output.json
submit_exit="${PIPESTATUS[0]}"
set -e

- name: Show notarization submission output
if: always()
run: |
cat /tmp/notary-submit-output.json
submission_id="$(/usr/bin/plutil -extract id raw -o - /tmp/notary-submit-output.json 2>/dev/null || true)"
status="$(/usr/bin/plutil -extract status raw -o - /tmp/notary-submit-output.json 2>/dev/null || true)"

- name: Fetch notarization log on rejection
run: |
submission_id="$(/usr/bin/plutil -extract id raw -o - /tmp/notary-submit-output.json)"
status="$(/usr/bin/plutil -extract status raw -o - /tmp/notary-submit-output.json)"

if [ "$status" = "Accepted" ]; then
if [ "$submit_exit" -eq 0 ] && [ "$status" = "Accepted" ]; then
exit 0
fi

echo "Notarization status: $status"
xcrun notarytool log "$submission_id" \
--keychain-profile "$KEYCHAIN_PROFILE" \
--keychain "$KEYCHAIN_PATH" \
/tmp/notary-log.json
cat /tmp/notary-log.json
echo "Notarization failed (exit $submit_exit, status: ${status:-unknown})"
if [ -n "$submission_id" ]; then
xcrun notarytool log "$submission_id" \
--keychain-profile "$KEYCHAIN_PROFILE" \
--keychain "$KEYCHAIN_PATH" \
/tmp/notary-log.json || true
fi
if [ -f /tmp/notary-log.json ]; then
cat /tmp/notary-log.json
fi
exit 1

- name: Staple notarization ticket
Expand All @@ -228,31 +254,30 @@ jobs:
spctl --assess --type execute --verbose=4 "$APP_PATH"

- name: Package signed release app
env:
TAG_NAME: ${{ github.ref_name }}
run: |
ditto -c -k --sequesterRsrc --keepParent \
"$APP_PATH" \
"$FINAL_ARCHIVE_PATH"

- name: Generate release notes
env:
TAG_NAME: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
chmod +x scripts/generate_release_notes.sh
scripts/generate_release_notes.sh "${TAG_NAME}" RELEASE_NOTES.md
scripts/generate_release_notes.sh "${RELEASE_TAG}" RELEASE_NOTES.md

- name: Publish GitHub release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
files: ${{ env.FINAL_ARCHIVE_PATH }}
body_path: RELEASE_NOTES.md
tag_name: ${{ env.RELEASE_TAG }}

- name: Clean up signing keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_PATH" || true
rm -f /tmp/octodot-developer-id.p12 /tmp/notary-submit-output.json /tmp/notary-log.json

update-homebrew:
needs: build-and-release
Expand All @@ -263,9 +288,10 @@ jobs:
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
test -n "$TAP_TOKEN"
VERSION="${RELEASE_TAG#v}"
URL="https://github.com/jasonlong/octodot/releases/download/v${VERSION}/Octodot-v${VERSION}-macos.zip"
ARCHIVE="Octodot-${GITHUB_REF_NAME}-macos.zip"
ARCHIVE="Octodot-${RELEASE_TAG}-macos.zip"
curl --fail --show-error --silent --location \
--retry 5 --retry-delay 2 --retry-all-errors \
--output "$ARCHIVE" "$URL"
Expand All @@ -277,7 +303,7 @@ jobs:
exit 1
fi

git clone https://x-access-token:${TAP_TOKEN}@github.com/jasonlong/homebrew-tap.git tap
git clone https://github.com/jasonlong/homebrew-tap.git tap
cd tap

cat > Casks/octodot.rb << CASK
Expand All @@ -293,11 +319,6 @@ jobs:
auto_updates true
depends_on macos: ">= :sonoma"

preflight do
system_command "/usr/bin/xattr",
args: ["-cr", "#{staged_path}/Octodot.app"]
end

app "Octodot.app"

zap trash: [
Expand All @@ -310,5 +331,10 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/octodot.rb
if git diff --cached --quiet; then
echo "Homebrew cask already matches ${VERSION}; nothing to commit."
exit 0
fi
git commit -m "Update octodot to ${VERSION}"
git push
auth_header="$(printf 'x-access-token:%s' "$TAP_TOKEN" | base64 | tr -d '\n')"
git -c http.https://github.com/.extraheader="AUTHORIZATION: basic $auth_header" push
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ Always rebuild after code changes so the user can test immediately. Use `killall

## Architecture

- **AppState** (`App/AppState.swift`): Central `@MainActor @Observable` model. Owns all notification data, selection, search, auth, background refresh. ~1200 lines. All UI state derives from here.
- **AppState** (`App/AppState.swift`): Central `@MainActor @Observable` model. Owns all notification data, selection, search, auth, and background refresh. All UI state derives from here.
- **GitHubAPIClient** (`Auth/GitHubAPIClient.swift`): `actor` for thread-safe API calls with caching, pagination, conditional polling (`If-Modified-Since`/304), and rate limit awareness.
- **InboxStore** (`App/InboxStore.swift`): Manages inbox projection — tracks recent reads, pruning, security alert state. Persists to UserDefaults.
- **ThreadActionStore** (`App/ThreadActionStore.swift`): Optimistic actions (done, unsubscribe) with batched dispatch. Reconciles with server on refresh.
- **ThreadActionStore** (`App/ThreadActionStore.swift`): Optimistic actions (mark read, done, unsubscribe) with batched dispatch. Reconciles with server on refresh.
- **StatusItemController** (`Panel/StatusItemController.swift`): Menu bar icon, panel toggle, global hotkey (Carbon Events), right-click context menu, outside-click dismiss.
- **NotificationPanel** (`Panel/NotificationPanel.swift`): `NSPanel` hosting SwiftUI via `NSHostingView`. Floating, non-activating, status bar level.
- **PanelInput** (`Views/PanelInput.swift`): All keyboard routing. Vim-style (`j/k/d/x/u/o/gg/G`) plus standard shortcuts (`Cmd+Up/Down`, arrows, Page Up/Down).
Expand All @@ -31,7 +31,7 @@ Always rebuild after code changes so the user can test immediately. Use `killall
- **NetworkSession protocol** for testable networking. Production uses `URLSession.shared`, tests use `StubNetworkSession`.
- **Race condition prevention**: UUID-based `activeLoadRequestID` pattern — generate ID before async work, check it after `await`.
- **Narrow view dependencies**: Pass specific values to row views (`let notification`, `let isSelected`), not entire state objects.
- **Equatable views** for list performance (`NotificationRowView: View, Equatable`).
- **Narrow row inputs** for list performance; keep list rows independent from the full app state.

## Testing

Expand All @@ -46,7 +46,7 @@ Uses Swift Testing framework (`import Testing`, `@Test`, `#expect`). Not XCTest.

- **Entitlements**: Network client only (no sandbox). Hardened runtime for notarization.
- **Token storage**: Keychain in release, file (`~/Library/Application Support/Octodot/.debug-token`) in debug.
- **Debug logging**: `DebugTrace.log()` — compiled out in release builds (`#if DEBUG`). Writes to `/tmp/octodot-debug-trace.log`.
- **Debug logging**: `DebugTrace.log()` — compiled out in release builds (`#if DEBUG`). Writes to the current user's temporary directory.
- **Version config**: `OCTODOT_MARKETING_VERSION` in project.pbxproj. CI overrides from git tag.
- **New files**: Must be added to `project.pbxproj` manually (PBXFileReference, PBXBuildFile, PBXGroup children, PBXSourcesBuildPhase). Follow existing ID patterns (`AA00...` for refs, `BB00...` for build files).
- **Commit style**: Imperative mood, concise subject line. Body explains "why" not "what".
11 changes: 9 additions & 2 deletions Octodot.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
BB0000000000000000000030 /* InboxStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000030 /* InboxStore.swift */; };
BB0000000000000000000032 /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* SemanticVersion.swift */; };
BB0000000000000000000033 /* UpdateChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000033 /* UpdateChecker.swift */; };
BB0000000000000000000036 /* UpdateInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000036 /* UpdateInstaller.swift */; };
BB0000000000000000000034 /* SemanticVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000034 /* SemanticVersionTests.swift */; };
BB0000000000000000000035 /* UpdateCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000035 /* UpdateCheckerTests.swift */; };
BB0000000000000000000020 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* AppStateTests.swift */; };
Expand Down Expand Up @@ -81,6 +82,7 @@
AA0000000000000000000030 /* InboxStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxStore.swift; sourceTree = "<group>"; };
AA0000000000000000000032 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = "<group>"; };
AA0000000000000000000033 /* UpdateChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChecker.swift; sourceTree = "<group>"; };
AA0000000000000000000036 /* UpdateInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateInstaller.swift; sourceTree = "<group>"; };
AA0000000000000000000034 /* SemanticVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersionTests.swift; sourceTree = "<group>"; };
AA0000000000000000000035 /* UpdateCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCheckerTests.swift; sourceTree = "<group>"; };
AA0000000000000000000010 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -136,6 +138,7 @@
AA0000000000000000000032 /* SemanticVersion.swift */,
AA0000000000000000000029 /* ThreadActionStore.swift */,
AA0000000000000000000033 /* UpdateChecker.swift */,
AA0000000000000000000036 /* UpdateInstaller.swift */,
);
path = App;
sourceTree = "<group>";
Expand Down Expand Up @@ -317,6 +320,7 @@
/* Begin PBXShellScriptBuildPhase section */
DD0000000000000000000015 /* Remove Legacy Icon Key */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
Expand Down Expand Up @@ -361,6 +365,7 @@
BB0000000000000000000027 /* DebugTrace.swift in Sources */,
BB0000000000000000000032 /* SemanticVersion.swift in Sources */,
BB0000000000000000000033 /* UpdateChecker.swift in Sources */,
BB0000000000000000000036 /* UpdateInstaller.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -446,11 +451,12 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OCTODOT_BUILD_NUMBER = 1;
OCTODOT_MARKETING_VERSION = 0.5.7;
OCTODOT_MARKETING_VERSION = 0.5.8;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = complete;
};
name = Debug;
};
Expand Down Expand Up @@ -502,10 +508,11 @@
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_FAST_MATH = YES;
OCTODOT_BUILD_NUMBER = 1;
OCTODOT_MARKETING_VERSION = 0.5.7;
OCTODOT_MARKETING_VERSION = 0.5.8;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_STRICT_CONCURRENCY = complete;
};
name = Release;
};
Expand Down
Loading
Loading