diff --git a/.claude/commands/bump-version.md b/.claude/commands/bump-version.md new file mode 100644 index 0000000..63a21a0 --- /dev/null +++ b/.claude/commands/bump-version.md @@ -0,0 +1,32 @@ +@scripts/update-version.sh を使ってバージョンを更新する + +スラッシュコマンド `/bump-version` を引数なしで実行した場合は、デフォルトで `patch` バージョンを更新します。 + +## 使用方法 + +### 増分指定でバージョンを更新 +- `patch` - パッチバージョンを増やす (0.7.0 → 0.7.1) +- `minor` - マイナーバージョンを増やす (0.7.0 → 0.8.0) +- `major` - メジャーバージョンを増やす (0.7.0 → 1.0.0) + +### 特定のバージョンを指定 +- 例: `0.8.0` + +## 実行例 + +```bash +# パッチバージョンを上げる +./scripts/update-version.sh patch + +# マイナーバージョンを上げる +./scripts/update-version.sh minor + +# 特定のバージョンに設定 +./scripts/update-version.sh 0.8.0 +``` + +## 実行後の処理 + +1. 変更内容を確認 +2. CHANGELOG.mdを更新(必要に応じて) +3. コミット: `git add Info.plist CHANGELOG.md && git commit -m "chore: bump version to "` diff --git a/.claude/commands/start-work.md b/.claude/commands/start-work.md new file mode 100644 index 0000000..8b2fb5c --- /dev/null +++ b/.claude/commands/start-work.md @@ -0,0 +1,46 @@ +--- +description: 作業環境をセットアップしてClaude Codeを起動 +allowed-tools: Bash(git *), Bash(tmux *), Read, Write +--- + +## 環境確認 +- Git status: !`git status --porcelain | head -5 || echo "✅ 作業ツリーはクリーンです"` +- Tmux: !`which tmux >/dev/null && echo "✅ tmux is installed" || echo "❌ tmux not found"` +- 現在のブランチ: !`git branch --show-current` +- 既存のworktrees: !`git worktree list | tail -n +2 || echo "No worktrees found"` + +## タスク +作業内容: **{{ARGUMENTS}}** + +以下の手順で作業環境をセットアップしてください: + +1. まず、作業内容から適切なブランチタイプとブランチ名を決定してください: + - 「実装」「追加」「機能」→ `feature/` + - 「修正」「バグ」「エラー」「失敗」→ `fix/` + - 「更新」「ドキュメント」「README」→ `docs/` + - 「リファクタ」「改善」→ `refactor/` + - その他 → `chore/` + +2. ブランチ名は以下のルールで生成してください: + - 日本語を英語に変換(例:リリース→release、失敗→failure、調査→investigate) + - スペースをハイフンに変換 + - 小文字に統一 + +3. 以下のコマンドを実行してください: + +```bash +# developブランチを更新 +git fetch origin develop:develop + +# worktreeを作成(ブランチ名を適切に置き換えてください) +git worktree add -b [ブランチタイプ]/[ブランチ名] ../worktrees/[ブランチ名] develop + +# tmuxセッションを作成してClaude Codeを起動 +tmux new-session -d -s claude-[ブランチ名] -c ../worktrees/[ブランチ名] "claude code" +``` + +4. セッション作成後、以下の情報を表示してください: + - 接続方法: `tmux attach -t claude-[ブランチ名]` + - 片付け方法: + - `tmux kill-session -t claude-[ブランチ名]` + - `git worktree remove ../worktrees/[ブランチ名]` \ No newline at end of file diff --git a/.github/workflows/build-and-sign.yml b/.github/workflows/build-and-sign.yml new file mode 100644 index 0000000..c2676df --- /dev/null +++ b/.github/workflows/build-and-sign.yml @@ -0,0 +1,388 @@ +name: Build and Sign + +on: + workflow_call: + inputs: + version: + required: true + type: string + is_dev_build: + required: false + type: boolean + default: false + create_release: + required: false + type: boolean + default: false + release_tag: + required: false + type: string + default: '' + secrets: + CERTIFICATES_P12: + required: false + CERTIFICATES_PASSWORD: + required: false + NOTARIZATION_APPLE_ID: + required: false + NOTARIZATION_PASSWORD: + required: false + NOTARIZATION_TEAM_ID: + required: false + outputs: + dmg_path: + description: "Path to the created DMG file" + value: ${{ jobs.build.outputs.dmg_path }} + +jobs: + build: + name: Build and Sign App + runs-on: macos-latest + outputs: + dmg_path: ${{ steps.create_dmg.outputs.dmg_path }} + + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + env: + XCODE_VERSION: '16.2' + run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" + + # Check if we have signing certificates + - name: Check signing prerequisites + id: check_signing + env: + CERT_P12: ${{ secrets.CERTIFICATES_P12 }} + CERT_PWD: ${{ secrets.CERTIFICATES_PASSWORD }} + NOTARIZE_ID: ${{ secrets.NOTARIZATION_APPLE_ID }} + NOTARIZE_PWD: ${{ secrets.NOTARIZATION_PASSWORD }} + NOTARIZE_TEAM: ${{ secrets.NOTARIZATION_TEAM_ID }} + run: | + if [ -n "$CERT_P12" ] && [ -n "$CERT_PWD" ]; then + echo "has_signing_cert=true" >> "$GITHUB_OUTPUT" + echo "✅ Signing certificates found" + else + echo "has_signing_cert=false" >> "$GITHUB_OUTPUT" + echo "⚠️ No signing certificates configured" + fi + + if [ -n "$NOTARIZE_ID" ] && [ -n "$NOTARIZE_PWD" ] && [ -n "$NOTARIZE_TEAM" ]; then + echo "has_notarization=true" >> "$GITHUB_OUTPUT" + echo "✅ Notarization credentials found" + else + echo "has_notarization=false" >> "$GITHUB_OUTPUT" + echo "⚠️ No notarization credentials configured" + fi + + # Setup keychain if we have certificates + - name: Set up keychain + if: steps.check_signing.outputs.has_signing_cert == 'true' + run: | + set -euo pipefail + + # Create a temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Add to search list + security list-keychains -d user -s "$KEYCHAIN_PATH" "$(security list-keychains -d user | sed 's/"//g')" + security default-keychain -s "$KEYCHAIN_PATH" + + # Store for later use + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" + + # Import certificates + - name: Import certificates + if: steps.check_signing.outputs.has_signing_cert == 'true' + env: + CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }} + CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }} + run: | + set -euo pipefail + + # Setup cleanup trap + CERT_FILE="certificate.p12" + trap 'rm -f "$CERT_FILE"' EXIT + + # Decode and import certificate + echo "$CERTIFICATES_P12" | base64 --decode > "$CERT_FILE" + + security import "$CERT_FILE" \ + -k "$KEYCHAIN_PATH" \ + -P "$CERTIFICATES_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/xcrun + + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + + # Find the certificate name dynamically + echo "=== Looking for certificates ===" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + # Extract certificate name safely + CERT_INFO=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1) + + if [ -z "$CERT_INFO" ]; then + echo "❌ No Developer ID Application certificate found" + echo "Available certificates:" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + exit 1 + fi + + # Safe extraction of certificate name using awk to avoid command injection + CERT_NAME=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') + + if [ -n "$CERT_NAME" ]; then + echo "✅ Found certificate: '$CERT_NAME'" + # Sanitize certificate name before setting as env var + printf '%s\n' "CERT_NAME=$CERT_NAME" >> "$GITHUB_ENV" + else + echo "❌ Failed to extract certificate name" + exit 1 + fi + + # Build the app + - name: Create app bundle + run: | + # Use the create-app-bundle.sh script + ./scripts/create-app-bundle.sh "${{ inputs.version }}" + + # Sign the app + - name: Sign app bundle + if: steps.check_signing.outputs.has_signing_cert == 'true' + run: | + echo "🔏 Signing app with: $CERT_NAME" + + # Debug: Check certificate availability + echo "=== Available certificates in keychain ===" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + # Debug: Check if CERT_NAME is properly set + if [ -z "$CERT_NAME" ]; then + echo "❌ CERT_NAME is empty!" + exit 1 + fi + + # Debug: Check entitlements file + if [ ! -f "ClaudeCodeMonitor.entitlements" ]; then + echo "❌ Entitlements file not found!" + ls -la + exit 1 + fi + + # Clean up app bundle root (just in case) + echo "=== Cleaning app bundle root ===" + find "ClaudeCodeMonitor.app" -maxdepth 1 -type f -delete 2>/dev/null || true + find "ClaudeCodeMonitor.app" -maxdepth 1 -name "*.bundle" -type d -exec rm -rf {} + 2>/dev/null || true + + # Verify clean structure before signing + echo "=== App bundle root after cleanup ===" + ls -la "ClaudeCodeMonitor.app/" + + # Sign Sparkle.framework components if it exists + if [ -d "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework" ]; then + echo "=== Signing Sparkle.framework components ===" + + # Sign individual binaries first + if [ -f "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" ]; then + echo " Signing Autoupdate binary..." + codesign --force --options runtime --sign "$CERT_NAME" --timestamp \ + "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" + fi + + if [ -f "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework/Versions/B/Sparkle" ]; then + echo " Signing Sparkle binary..." + codesign --force --options runtime --sign "$CERT_NAME" --timestamp \ + "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework/Versions/B/Sparkle" + fi + + # Sign Updater.app + if [ -d "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" ]; then + echo " Signing Updater.app..." + codesign --force --deep --options runtime --sign "$CERT_NAME" --timestamp \ + "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" + fi + + # Sign XPCServices + if [ -d "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices" ]; then + echo " Signing XPC Services..." + find "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices" \ + -name "*.xpc" -exec codesign --force --deep --options runtime --sign "$CERT_NAME" --timestamp {} \; + fi + + # Finally sign the framework itself + echo " Signing Sparkle.framework..." + codesign --force --options runtime --sign "$CERT_NAME" --timestamp \ + "ClaudeCodeMonitor.app/Contents/Frameworks/Sparkle.framework" + fi + + # Sign the app with --deep to ensure all components are signed + echo "=== Attempting to sign app with --deep ===" + codesign --force --deep --strict \ + --options runtime \ + --entitlements ClaudeCodeMonitor.entitlements \ + --sign "$CERT_NAME" \ + --timestamp \ + "ClaudeCodeMonitor.app" + + # Verify signature with detailed output + echo "=== Verifying signature ===" + codesign --verify --deep --strict --verbose=4 "ClaudeCodeMonitor.app" + + echo "=== Signature details ===" + codesign -dvvv "ClaudeCodeMonitor.app" + + echo "=== Verifying with spctl ===" + spctl -a -vvv -t install "ClaudeCodeMonitor.app" || echo "Note: spctl check may fail in CI environment" + + # Ad-hoc sign if no certificates + - name: Ad-hoc sign app bundle + if: steps.check_signing.outputs.has_signing_cert != 'true' + run: | + echo "⚠️ Ad-hoc signing app (no Developer ID certificate)" + codesign --force --deep --sign - "ClaudeCodeMonitor.app" + + # Create DMG + - name: Create DMG + id: create_dmg + run: | + # Install create-dmg if needed + if ! command -v create-dmg &> /dev/null; then + brew install create-dmg + fi + + DMG_NAME="ClaudeCodeMonitor-${{ inputs.version }}.dmg" + + # Set volume name based on build type + if [ "${{ inputs.is_dev_build }}" == "true" ]; then + VOLUME_NAME="Claude Code Monitor Dev" + else + VOLUME_NAME="Claude Code Monitor" + fi + + # Create DMG + create-dmg \ + --volname "$VOLUME_NAME" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "ClaudeCodeMonitor.app" 150 185 \ + --hide-extension "ClaudeCodeMonitor.app" \ + --app-drop-link 450 185 \ + --no-internet-enable \ + --hdiutil-quiet \ + "$DMG_NAME" \ + "ClaudeCodeMonitor.app" + + echo "DMG_PATH=$DMG_NAME" >> "$GITHUB_ENV" + echo "dmg_path=$DMG_NAME" >> "$GITHUB_OUTPUT" + echo "✅ Created DMG: $DMG_NAME" + + # Sign DMG + - name: Sign DMG + if: steps.check_signing.outputs.has_signing_cert == 'true' + run: | + echo "🔏 Signing DMG..." + codesign --force --sign "$CERT_NAME" --timestamp "$DMG_PATH" + codesign --verify --verbose=2 "$DMG_PATH" + + # Notarize using action + - name: Notarize app + if: steps.check_signing.outputs.has_signing_cert == 'true' && steps.check_signing.outputs.has_notarization == 'true' + uses: lando/notarize-action@v2 + with: + product-path: ${{ env.DMG_PATH }} + appstore-connect-username: ${{ secrets.NOTARIZATION_APPLE_ID }} + appstore-connect-password: ${{ secrets.NOTARIZATION_PASSWORD }} + appstore-connect-team-id: ${{ secrets.NOTARIZATION_TEAM_ID }} + verbose: true + + # Staple notarization + - name: Staple notarization + if: steps.check_signing.outputs.has_signing_cert == 'true' && steps.check_signing.outputs.has_notarization == 'true' + run: | + echo "📎 Stapling notarization..." + echo "DMG_PATH: $DMG_PATH" + + # Check if DMG exists + if [ ! -f "$DMG_PATH" ]; then + echo "❌ DMG file not found at: $DMG_PATH" + ls -la + exit 1 + fi + + # Verify DMG signature before stapling + echo "=== Verifying DMG signature before stapling ===" + codesign --verify --verbose=2 "$DMG_PATH" + + # Attempt to staple + echo "=== Stapling notarization ticket ===" + xcrun stapler staple -v "$DMG_PATH" + + # Validate the staple + echo "=== Validating stapled notarization ===" + xcrun stapler validate -v "$DMG_PATH" + + # Generate changelog for development releases + - name: Generate changelog + if: inputs.create_release == true && inputs.is_dev_build == true + id: changelog + run: | + { + echo "body</dev/null || echo "") + if [ -n "$LAST_TAG" ]; then + git log --pretty=format:"* %s (%h)" "$LAST_TAG"..HEAD | head -20 + else + git log --pretty=format:"* %s (%h)" -20 + fi + + echo "" + echo "CHANGELOG_EOF" + } >> "$GITHUB_OUTPUT" + + # Create GitHub Release if requested + - name: Create Release + if: inputs.create_release == true + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.release_tag }} + name: ${{ inputs.is_dev_build == true && format('Development Build {0}', inputs.version) || format('Release {0}', inputs.version) }} + body: ${{ steps.changelog.outputs.body || '' }} + draft: false + prerelease: ${{ inputs.is_dev_build }} + files: ${{ env.DMG_PATH }} + + # Always upload DMG as artifact for downstream jobs + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: dmg-artifact + path: ${{ env.DMG_PATH }} + retention-days: 1 + + # Cleanup + - name: Clean up keychain + if: always() && steps.check_signing.outputs.has_signing_cert == 'true' + run: | + if [ -n "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3a8476..4c23d56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] jobs: build: @@ -15,7 +15,9 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_15.2.app + env: + XCODE_VERSION: '16.2' + run: sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app" - name: Build Debug run: swift build -v @@ -33,13 +35,13 @@ jobs: runs-on: macos-latest strategy: matrix: - xcode: ['15.0', '15.2'] + xcode: ['15.4', '16.2'] steps: - uses: actions/checkout@v4 - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + run: sudo xcode-select -s "/Applications/Xcode_${{ matrix.xcode }}.app" - name: Show Swift version run: swift --version @@ -62,7 +64,7 @@ jobs: swift test --filter ViewModelTests -v) - name: Generate coverage report - if: matrix.xcode == '15.2' + if: matrix.xcode == '16.2' run: | xcrun llvm-cov export \ .build/debug/ClaudeCodeMonitorPackageTests.xctest/Contents/MacOS/ClaudeCodeMonitorPackageTests \ @@ -70,7 +72,7 @@ jobs: -format="lcov" > coverage.lcov - name: Upload coverage to Codecov - if: matrix.xcode == '15.2' + if: matrix.xcode == '16.2' uses: codecov/codecov-action@v4 with: file: ./coverage.lcov diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..babf5fa --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,282 @@ +name: PR Validation + +on: + pull_request: + branches: [develop, main] + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + validate-version: + name: Validate Version Update + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check which branch we're merging to + id: target + env: + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + run: | + echo "base_branch=$BASE_REF" >> "$GITHUB_OUTPUT" + echo "head_branch=$HEAD_REF" >> "$GITHUB_OUTPUT" + echo "Merging from $HEAD_REF to $BASE_REF" + + # Validation for all branches -> develop PRs (except hotfix) + - name: Validate branch to develop + if: github.base_ref == 'develop' && github.head_ref != 'develop' && !startsWith(github.head_ref, 'hotfix/') + id: version_check + env: + HEAD_REF: ${{ github.head_ref }} + run: | + echo "## Validating $HEAD_REF → develop PR" + + # Get version from base branch (develop) + git fetch origin develop + BASE_VERSION=$(git show origin/develop:Info.plist | grep -A1 "CFBundleShortVersionString" | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + BASE_BUILD_VERSION=$(git show origin/develop:Info.plist | grep -A1 "CFBundleVersion" | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + echo "Develop branch version: $BASE_VERSION (build: $BASE_BUILD_VERSION)" + + # Get version from PR branch + PR_VERSION=$(grep -A1 "CFBundleShortVersionString" Info.plist | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + PR_BUILD_VERSION=$(grep -A1 "CFBundleVersion" Info.plist | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + echo "PR branch version: $PR_VERSION (build: $PR_BUILD_VERSION)" + + # Save versions to outputs + echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" + echo "pr_version=$PR_VERSION" >> "$GITHUB_OUTPUT" + + # Check if version was updated + if [ "$BASE_VERSION" = "$PR_VERSION" ]; then + echo "❌ Error: Version not updated in Info.plist" + echo "Please update the version number when merging to develop" + echo "" + echo "Current version: $BASE_VERSION" + echo "Expected: A higher version number" + echo "validation_failed=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + + # Check if CFBundleVersion matches CFBundleShortVersionString + if [ "$PR_VERSION" != "$PR_BUILD_VERSION" ]; then + echo "❌ Error: Version mismatch in Info.plist" + echo "CFBundleShortVersionString: $PR_VERSION" + echo "CFBundleVersion: $PR_BUILD_VERSION" + echo "Both values should be the same" + echo "validation_failed=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + + # Validate version format (should be x.y.z) + if ! echo "$PR_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "❌ Error: Invalid version format" + echo "Version should be in format: x.y.z (e.g., 1.2.3)" + echo "Found: $PR_VERSION" + echo "validation_failed=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + + echo "✅ Version updated: $BASE_VERSION → $PR_VERSION" + echo "validation_failed=false" >> "$GITHUB_OUTPUT" + echo "validation_passed=true" >> "$GITHUB_OUTPUT" + + # Comment on PR if validation failed + - name: Comment on PR about version update + if: always() && github.base_ref == 'develop' && github.head_ref != 'develop' && !startsWith(github.head_ref, 'hotfix/') && steps.version_check.outcome == 'failure' + uses: actions/github-script@v7 + with: + script: | + const baseVersion = '${{ steps.version_check.outputs.base_version }}'; + const prVersion = '${{ steps.version_check.outputs.pr_version }}'; + + const comment = `## ❌ Version Update Required + + This PR is merging to \`develop\` but the version in \`Info.plist\` has not been updated. + + **Current version:** \`${baseVersion}\` + **PR version:** \`${prVersion}\` + + Please update the version number in \`Info.plist\` before this PR can be merged. + + ### How to update: + 1. Edit \`Info.plist\` + 2. Update both \`CFBundleShortVersionString\` and \`CFBundleVersion\` values to the same version + 3. Commit and push the changes + + Or use the update script: + \`\`\`bash + ./scripts/update-version.sh patch # or minor/major + \`\`\` + + The version should follow semantic versioning (x.y.z format).`; + + // Check if we already commented + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Version Update Required') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment, + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment, + }); + } + + # Delete comment if validation passed + - name: Delete version update comment if validation passed + if: github.base_ref == 'develop' && github.head_ref != 'develop' && !startsWith(github.head_ref, 'hotfix/') && steps.version_check.outputs.validation_passed == 'true' + uses: actions/github-script@v7 + with: + script: | + // Find and delete any existing version update comments + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComments = comments.filter(comment => + comment.user.type === 'Bot' && + comment.body.includes('Version Update Required') + ); + + for (const comment of botComments) { + console.log(`Deleting outdated version comment: ${comment.id}`); + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + if (botComments.length > 0) { + console.log(`✅ Deleted ${botComments.length} outdated version comment(s)`); + } + + # Validation for develop -> main PRs + - name: Validate develop to main + if: github.base_ref == 'main' && github.head_ref == 'develop' + run: | + echo "## Validating develop → main PR" + + # Check if CHANGELOG.md was updated + if ! git diff origin/main --name-only | grep -q "CHANGELOG.md"; then + echo "❌ Error: CHANGELOG.md not updated" + echo "Please update CHANGELOG.md with release notes before merging to main" + exit 1 + fi + + echo "✅ CHANGELOG.md has been updated" + + # Get current version + VERSION=$(grep -A1 "CFBundleShortVersionString" Info.plist | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + BUILD_VERSION=$(grep -A1 "CFBundleVersion" Info.plist | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + echo "Release version: $VERSION (build: $BUILD_VERSION)" + + # Check if versions match + if [ "$VERSION" != "$BUILD_VERSION" ]; then + echo "❌ Error: Version mismatch" + echo "CFBundleShortVersionString: $VERSION" + echo "CFBundleVersion: $BUILD_VERSION" + echo "Both values should be the same" + exit 1 + fi + + # Check if this version already exists as a tag + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "⚠️ Warning: Version v$VERSION already exists as a tag" + echo "The release workflow will skip creating a new release" + fi + + # Validation for hotfix -> main PRs + - name: Validate hotfix to main + if: github.base_ref == 'main' && startsWith(github.head_ref, 'hotfix/') + run: | + echo "## Validating hotfix → main PR" + + # Get version from main branch + git fetch origin main + MAIN_VERSION=$(git show origin/main:Info.plist | grep -A1 "CFBundleShortVersionString" | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + echo "Main branch version: $MAIN_VERSION" + + # Get version from PR branch + PR_VERSION=$(grep -A1 "CFBundleShortVersionString" Info.plist | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + echo "Hotfix version: $PR_VERSION" + + # Check if version was updated + if [ "$MAIN_VERSION" = "$PR_VERSION" ]; then + echo "❌ Error: Version not updated in Info.plist" + echo "Hotfixes must increment the version number" + exit 1 + fi + + # Check CHANGELOG update + if ! git diff origin/main --name-only | grep -q "CHANGELOG.md"; then + echo "❌ Error: CHANGELOG.md not updated" + echo "Please document the hotfix in CHANGELOG.md" + exit 1 + fi + + echo "✅ Hotfix validation passed" + + check-conventional-commits: + name: Check Commit Messages + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check commit format + run: | + echo "## Checking commit message format" + + # Get commits in this PR + COMMITS=$(git log --format="%s" origin/${{ github.base_ref }}..HEAD) + + # Check if commits follow conventional format + INVALID_COMMITS="" + while IFS= read -r commit; do + if ! echo "$commit" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .+' && \ + ! echo "$commit" | grep -qE '^Merge '; then + INVALID_COMMITS="${INVALID_COMMITS}❌ $commit\n" + fi + done <<< "$COMMITS" + + if [ -n "$INVALID_COMMITS" ]; then + echo "⚠️ Warning: Some commits don't follow Conventional Commits format:" + echo -e "$INVALID_COMMITS" + echo "" + echo "Expected format: type(scope): description" + echo "Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert" + echo "" + echo "This is a warning only - not blocking the PR" + else + echo "✅ All commits follow Conventional Commits format" + fi \ No newline at end of file diff --git a/.github/workflows/release-common.yml b/.github/workflows/release-common.yml new file mode 100644 index 0000000..7d673bd --- /dev/null +++ b/.github/workflows/release-common.yml @@ -0,0 +1,167 @@ +name: Common Release Workflow + +on: + workflow_call: + inputs: + is_dev_build: + required: true + type: boolean + branch_name: + required: true + type: string + +permissions: + contents: write + +jobs: + prepare: + name: Prepare Release + runs-on: macos-latest + outputs: + skip_release: ${{ steps.version.outputs.skip_release }} + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version + id: version + run: | + set -euo pipefail + + BASE_VERSION=$(defaults read "$PWD/Info.plist" CFBundleShortVersionString) + + if [ "${{ inputs.is_dev_build }}" == "true" ]; then + VERSION="${BASE_VERSION}-dev" + else + VERSION="${BASE_VERSION}" + fi + + TAG="v${VERSION}" + + # Check if this version tag already exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "⚠️ Version $TAG already exists. Skipping release." + echo "skip_release=true" >> $GITHUB_OUTPUT + else + echo "skip_release=false" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "✅ Preparing release for version: $VERSION" + fi + + build: + needs: prepare + if: needs.prepare.outputs.skip_release != 'true' + uses: ./.github/workflows/build-and-sign.yml + with: + version: ${{ needs.prepare.outputs.version }} + is_dev_build: ${{ inputs.is_dev_build }} + create_release: false # Always create release in the release job + secrets: inherit + + release: + needs: [prepare, build] + if: needs.prepare.outputs.skip_release != 'true' + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download DMG + uses: actions/download-artifact@v4 + with: + name: dmg-artifact + path: . + + - name: Update appcast on gh-pages + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + VERSION: ${{ needs.prepare.outputs.version }} + DMG_PATH: ClaudeCodeMonitor-${{ needs.prepare.outputs.version }}.dmg + run: | + set -euo pipefail + + # Check if DMG file exists + if [ ! -f "$DMG_PATH" ]; then + echo "❌ DMG file not found: $DMG_PATH" + echo "📁 Current directory contents:" + ls -la + exit 1 + fi + + # Configure git + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + # Call update-appcast script + IS_DEV=${{ inputs.is_dev_build }} + ./scripts/update-appcast.sh "$VERSION" "$IS_DEV" + + - name: Create and push tag + run: | + set -euo pipefail + + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git tag -a "${{ needs.prepare.outputs.tag }}" -m "Release ${{ needs.prepare.outputs.tag }}" + git push origin "${{ needs.prepare.outputs.tag }}" + + - name: Generate changelog + id: changelog + run: | + set -euo pipefail + + # Create changelog file + if [ "${{ inputs.is_dev_build }}" == "true" ]; then + { + echo "## 🚧 Development Release ${{ needs.prepare.outputs.version }}" + echo "" + echo "This is an automated development release from the develop branch." + echo "For testing purposes only. Not recommended for production use." + } > changelog.md + else + { + echo "## 🎉 Release ${{ needs.prepare.outputs.version }}" + echo "" + echo "### What's Changed" + } > changelog.md + fi + + echo "" >> changelog.md + + # Get commits since last tag + LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$LAST_TAG" ]; then + echo "### Recent Changes" >> changelog.md + echo "" >> changelog.md + git log --pretty=format:"* %s (%h)" "$LAST_TAG"..HEAD >> changelog.md + else + echo "### All Changes" >> changelog.md + echo "" >> changelog.md + git log --pretty=format:"* %s (%h)" -20 >> changelog.md + fi + + echo "" >> changelog.md + + # Add full changelog link for stable releases + if [ "${{ inputs.is_dev_build }}" != "true" ] && [ -n "$LAST_TAG" ]; then + echo "" >> changelog.md + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$LAST_TAG...${{ needs.prepare.outputs.tag }}" >> changelog.md + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.prepare.outputs.tag }} + name: ${{ inputs.is_dev_build == true && format('Development Build {0}', needs.prepare.outputs.version) || format('Release {0}', needs.prepare.outputs.version) }} + body_path: changelog.md + draft: false + prerelease: ${{ inputs.is_dev_build }} + files: | + *.dmg \ No newline at end of file diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml new file mode 100644 index 0000000..6447f43 --- /dev/null +++ b/.github/workflows/release-dev.yml @@ -0,0 +1,17 @@ +name: Development Release + +on: + push: + branches: + - develop + +permissions: + contents: write + +jobs: + release: + uses: ./.github/workflows/release-common.yml + with: + is_dev_build: true + branch_name: develop + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000..47708c1 --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,17 @@ +name: Stable Release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + release: + uses: ./.github/workflows/release-common.yml + with: + is_dev_build: false + branch_name: main + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index efcb8fc..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,359 +0,0 @@ -name: Release - -on: - pull_request: - types: [closed] - branches: - - main - -permissions: - contents: write - -jobs: - build: - name: Build and Release - runs-on: macos-latest - if: github.event.pull_request.merged == true - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_15.2.app - - - name: Determine version bump type - id: version_type - run: | - # Check PR labels for version type - LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}" - echo "PR Labels: $LABELS" - - if [[ "$LABELS" == *"version:major"* ]]; then - echo "type=major" >> $GITHUB_OUTPUT - elif [[ "$LABELS" == *"version:minor"* ]]; then - echo "type=minor" >> $GITHUB_OUTPUT - else - echo "type=patch" >> $GITHUB_OUTPUT - fi - - - name: Determine next version - id: version - run: | - VERSION_TYPE="${{ steps.version_type.outputs.type }}" - echo "Determining next version: $VERSION_TYPE" - - # Check if get-next-version.sh exists and is executable - if [ -f "./scripts/get-next-version.sh" ] && [ -x "./scripts/get-next-version.sh" ]; then - echo "Using get-next-version.sh" - # Get next version from git tags and output to GITHUB_OUTPUT - ./scripts/get-next-version.sh "$VERSION_TYPE" - else - echo "Warning: get-next-version.sh not found or not executable" - echo "Falling back to manual version calculation" - - # Fallback: Calculate version manually - LATEST_TAG=$(git tag -l "v*.*.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" | sort -V | tail -1) - if [ -z "$LATEST_TAG" ]; then - CURRENT_VERSION="0.0.0" - else - CURRENT_VERSION=${LATEST_TAG#v} - fi - - # Parse and increment version - IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" - MAJOR="${VERSION_PARTS[0]:-0}" - MINOR="${VERSION_PARTS[1]:-0}" - PATCH="${VERSION_PARTS[2]:-0}" - - case "$VERSION_TYPE" in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch|*) - PATCH=$((PATCH + 1)) - ;; - esac - - NEW_VERSION="$MAJOR.$MINOR.$PATCH" - - # Validate version - if [ -z "$NEW_VERSION" ] || [ "$NEW_VERSION" = "0.0.0-dev" ]; then - echo "Error: Failed to determine version" - echo "Latest tags:" - git tag -l "v*.*.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" | sort -V | tail -5 - exit 1 - fi - - echo "New version: $NEW_VERSION" - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT - fi - - # Check if we have signing certificates - - name: Check signing prerequisites - id: check_signing - env: - CERT_P12: ${{ secrets.CERTIFICATES_P12 }} - CERT_PWD: ${{ secrets.CERTIFICATES_PASSWORD }} - NOTARIZE_ID: ${{ secrets.NOTARIZATION_APPLE_ID }} - NOTARIZE_PWD: ${{ secrets.NOTARIZATION_PASSWORD }} - NOTARIZE_TEAM: ${{ secrets.NOTARIZATION_TEAM_ID }} - run: | - if [ -n "$CERT_P12" ] && [ -n "$CERT_PWD" ]; then - echo "has_signing_cert=true" >> $GITHUB_OUTPUT - echo "✅ Signing certificates found" - else - echo "has_signing_cert=false" >> $GITHUB_OUTPUT - echo "⚠️ No signing certificates configured" - fi - - if [ -n "$NOTARIZE_ID" ] && [ -n "$NOTARIZE_PWD" ] && [ -n "$NOTARIZE_TEAM" ]; then - echo "has_notarization=true" >> $GITHUB_OUTPUT - echo "✅ Notarization credentials found" - else - echo "has_notarization=false" >> $GITHUB_OUTPUT - echo "⚠️ No notarization credentials configured" - fi - - # Setup keychain if we have certificates - - name: Set up keychain - if: steps.check_signing.outputs.has_signing_cert == 'true' - run: | - # Create a temporary keychain - KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - # Add to search list - security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed 's/"//g') - security default-keychain -s "$KEYCHAIN_PATH" - - # Store for later use - echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV - echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV - - # Import certificates - - name: Import certificates - if: steps.check_signing.outputs.has_signing_cert == 'true' - env: - CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }} - CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }} - run: | - # Decode and import certificate - echo "$CERTIFICATES_P12" | base64 --decode > certificate.p12 - - security import certificate.p12 \ - -k "$KEYCHAIN_PATH" \ - -P "$CERTIFICATES_PASSWORD" \ - -T /usr/bin/codesign \ - -T /usr/bin/xcrun - - security set-key-partition-list \ - -S apple-tool:,apple:,codesign: \ - -s -k "$KEYCHAIN_PASSWORD" \ - "$KEYCHAIN_PATH" - - # Find the certificate name dynamically - echo "=== Looking for certificates ===" - security find-identity -v -p codesigning "$KEYCHAIN_PATH" - - # Extract certificate name more carefully - CERT_LINE=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1) - echo "Certificate line: $CERT_LINE" - - # Extract the name between quotes - CERT_NAME=$(echo "$CERT_LINE" | cut -d'"' -f2) - - if [ -n "$CERT_NAME" ] && [ "$CERT_NAME" != "" ]; then - echo "✅ Found certificate: '$CERT_NAME'" - echo "CERT_NAME=$CERT_NAME" >> $GITHUB_ENV - else - echo "❌ No Developer ID Application certificate found" - echo "Available certificates:" - security find-identity -v -p codesigning "$KEYCHAIN_PATH" - exit 1 - fi - - rm certificate.p12 - - # Build the app - - name: Create app bundle - run: | - # Use the create-app-bundle.sh script - ./scripts/create-app-bundle.sh "${{ steps.version.outputs.version }}" - - # Sign the app - - name: Sign app bundle - if: steps.check_signing.outputs.has_signing_cert == 'true' - run: | - echo "🔏 Signing app with: $CERT_NAME" - - # Debug: Check certificate availability - echo "=== Available certificates in keychain ===" - security find-identity -v -p codesigning "$KEYCHAIN_PATH" - - # Debug: Check if CERT_NAME is properly set - if [ -z "$CERT_NAME" ]; then - echo "❌ CERT_NAME is empty!" - exit 1 - fi - - # Debug: Check entitlements file - if [ ! -f "ClaudeCodeMonitor.entitlements" ]; then - echo "❌ Entitlements file not found!" - ls -la - exit 1 - fi - - # Clean up app bundle root (just in case) - echo "=== Cleaning app bundle root ===" - find "ClaudeCodeMonitor.app" -maxdepth 1 -type f -delete 2>/dev/null || true - find "ClaudeCodeMonitor.app" -maxdepth 1 -name "*.bundle" -type d -exec rm -rf {} + 2>/dev/null || true - - # Verify clean structure before signing - echo "=== App bundle root after cleanup ===" - ls -la "ClaudeCodeMonitor.app/" - - # Try signing (without --deep to preserve Node.js signature) - echo "=== Attempting to sign ===" - codesign --force --strict \ - --options runtime \ - --entitlements ClaudeCodeMonitor.entitlements \ - --sign "$CERT_NAME" \ - --timestamp \ - "ClaudeCodeMonitor.app" - - # Verify signature - codesign --verify --deep --strict --verbose=2 "ClaudeCodeMonitor.app" - codesign -dvvv "ClaudeCodeMonitor.app" - - # Ad-hoc sign if no certificates - - name: Ad-hoc sign app bundle - if: steps.check_signing.outputs.has_signing_cert != 'true' - run: | - echo "⚠️ Ad-hoc signing app (no Developer ID certificate)" - codesign --force --deep --sign - "ClaudeCodeMonitor.app" - - # Create DMG - - name: Create DMG - run: | - # Install create-dmg if needed - if ! command -v create-dmg &> /dev/null; then - brew install create-dmg - fi - - DMG_NAME="ClaudeCodeMonitor-${{ steps.version.outputs.version }}.dmg" - - # Create DMG - create-dmg \ - --volname "Claude Code Monitor" \ - --window-pos 200 120 \ - --window-size 600 400 \ - --icon-size 100 \ - --icon "ClaudeCodeMonitor.app" 150 185 \ - --hide-extension "ClaudeCodeMonitor.app" \ - --app-drop-link 450 185 \ - --no-internet-enable \ - --hdiutil-quiet \ - "$DMG_NAME" \ - "ClaudeCodeMonitor.app" - - echo "DMG_PATH=$DMG_NAME" >> $GITHUB_ENV - echo "✅ Created DMG: $DMG_NAME" - - # Sign DMG - - name: Sign DMG - if: steps.check_signing.outputs.has_signing_cert == 'true' - run: | - echo "🔏 Signing DMG..." - codesign --force --sign "$CERT_NAME" --timestamp "$DMG_PATH" - codesign --verify --verbose=2 "$DMG_PATH" - - # Notarize using action - - name: Notarize app - if: steps.check_signing.outputs.has_signing_cert == 'true' && steps.check_signing.outputs.has_notarization == 'true' - uses: lando/notarize-action@v2 - with: - product-path: ${{ env.DMG_PATH }} - appstore-connect-username: ${{ secrets.NOTARIZATION_APPLE_ID }} - appstore-connect-password: ${{ secrets.NOTARIZATION_PASSWORD }} - appstore-connect-team-id: ${{ secrets.NOTARIZATION_TEAM_ID }} - verbose: true - - # Staple notarization - - name: Staple notarization - if: steps.check_signing.outputs.has_signing_cert == 'true' && steps.check_signing.outputs.has_notarization == 'true' - run: | - echo "📎 Stapling notarization..." - xcrun stapler staple "$DMG_PATH" - xcrun stapler validate "$DMG_PATH" - - # Generate changelog - - name: Generate changelog - id: changelog - run: | - # Get previous tag - PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - - if [ -z "$PREV_TAG" ]; then - echo "## 🎉 Initial Release" > changelog.md - echo "" >> changelog.md - echo "First release of Claude Code Monitor!" >> changelog.md - else - echo "## What's Changed" > changelog.md - echo "" >> changelog.md - git log --pretty=format:"* %s (%h)" $PREV_TAG..HEAD >> changelog.md - echo "" >> changelog.md - echo "" >> changelog.md - echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$PREV_TAG...${{ steps.version.outputs.tag }}" >> changelog.md - fi - - # Set output - { - echo "changelog<> $GITHUB_OUTPUT - - # Create and push tag - - name: Create and push tag - run: | - git config user.name "GitHub Actions" - git config user.email "actions@github.com" - git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" - git push origin "${{ steps.version.outputs.tag }}" - - # Create release - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.tag }} - name: Release ${{ steps.version.outputs.version }} - body: ${{ steps.changelog.outputs.changelog }} - draft: false - prerelease: false - files: ${{ env.DMG_PATH }} - - # Cleanup - - name: Clean up keychain - if: always() && steps.check_signing.outputs.has_signing_cert == 'true' - run: | - if [ -n "$KEYCHAIN_PATH" ]; then - security delete-keychain "$KEYCHAIN_PATH" || true - fi - - # TODO: Update Homebrew formula - - name: Update Homebrew Formula - run: | - echo "ℹ️ Homebrew formula update not implemented yet" \ No newline at end of file diff --git a/.github/workflows/version-helper.yml b/.github/workflows/version-helper.yml new file mode 100644 index 0000000..28f15cd --- /dev/null +++ b/.github/workflows/version-helper.yml @@ -0,0 +1,202 @@ +name: Version Helper + +on: + pull_request: + branches: [develop, main] + types: [opened] + +permissions: + pull-requests: write + +jobs: + suggest-version: + name: Suggest Version Update + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Analyze commits and suggest version + id: analyze + env: + BASE_BRANCH: ${{ github.base_ref }} + HEAD_BRANCH: ${{ github.head_ref }} + run: | + + echo "Analyzing PR from $HEAD_BRANCH to $BASE_BRANCH" + + # Get current version from base branch + git fetch origin "$BASE_BRANCH" + CURRENT_VERSION=$(git show "origin/$BASE_BRANCH:Info.plist" | grep -A1 "CFBundleShortVersionString" | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + + # Get PR version + PR_VERSION=$(grep -A1 "CFBundleShortVersionString" Info.plist | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + + echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "pr_version=$PR_VERSION" >> "$GITHUB_OUTPUT" + + # Analyze commits + COMMITS=$(git log --format="%s" "origin/$BASE_BRANCH"..HEAD) + FEAT_COUNT=$(echo "$COMMITS" | grep -c "^feat" || true) + FIX_COUNT=$(echo "$COMMITS" | grep -c "^fix" || true) + BREAKING_COUNT=$(echo "$COMMITS" | grep -cE "(^feat!|^fix!|BREAKING CHANGE)" || true) + TOTAL_COMMITS=$(echo "$COMMITS" | wc -l) + + echo "feat_count=$FEAT_COUNT" >> "$GITHUB_OUTPUT" + echo "fix_count=$FIX_COUNT" >> "$GITHUB_OUTPUT" + echo "breaking_count=$BREAKING_COUNT" >> "$GITHUB_OUTPUT" + echo "total_commits=$TOTAL_COMMITS" >> "$GITHUB_OUTPUT" + + # Parse current version + IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" + MAJOR="${VERSION_PARTS[0]:-0}" + MINOR="${VERSION_PARTS[1]:-0}" + PATCH="${VERSION_PARTS[2]:-0}" + + # Suggest next version based on commits + if [ "$BREAKING_COUNT" -gt 0 ]; then + SUGGESTED="$((MAJOR + 1)).0.0" + SUGGESTION_REASON="Breaking changes detected" + elif [ "$FEAT_COUNT" -gt 0 ]; then + SUGGESTED="$MAJOR.$((MINOR + 1)).0" + SUGGESTION_REASON="New features added" + elif [ "$FIX_COUNT" -gt 0 ]; then + SUGGESTED="$MAJOR.$MINOR.$((PATCH + 1))" + SUGGESTION_REASON="Bug fixes only" + else + SUGGESTED="$MAJOR.$MINOR.$((PATCH + 1))" + SUGGESTION_REASON="Other changes" + fi + + echo "suggested_version=$SUGGESTED" >> "$GITHUB_OUTPUT" + echo "suggestion_reason=$SUGGESTION_REASON" >> "$GITHUB_OUTPUT" + + - name: Post PR comment for feature branch + if: github.base_ref == 'develop' && startsWith(github.head_ref, 'feature/') + uses: actions/github-script@v7 + with: + script: | + const output = ${{ toJson(steps.analyze.outputs) }}; + + const body = `## 🔧 Version Update Helper + + ### Current Status + - Base branch (\`develop\`) version: **${output.current_version}** + - Your branch version: **${output.pr_version}** + ${output.current_version === output.pr_version ? '- ❌ **Version needs to be updated**' : '- ✅ Version has been updated'} + + ### Commit Analysis + - Total commits: ${output.total_commits} + - New features (\`feat\`): ${output.feat_count} + - Bug fixes (\`fix\`): ${output.fix_count} + - Breaking changes: ${output.breaking_count} + + ### Version Suggestion + Based on your commits, suggested version: **${output.suggested_version}** + Reason: ${output.suggestion_reason} + + ### How to Update Version + \`\`\`bash + # Update to suggested version + ./scripts/update-version.sh ${output.suggested_version} + + # Or choose your own version + ./scripts/update-version.sh x.y.z + \`\`\` + + ### Checklist + - [ ] Update version in Info.plist + - [ ] Commit with message: \`chore: bump version to x.y.z\` + - [ ] Push changes to update this PR + `; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + - name: Post PR comment for develop to main + if: github.base_ref == 'main' && github.head_ref == 'develop' + uses: actions/github-script@v7 + with: + script: | + const output = ${{ toJson(steps.analyze.outputs) }}; + + const body = `## 📦 Release Preparation Helper + + ### Release Version + - Version to be released: **${output.pr_version}** + - This will be distributed as a stable release + + ### Recent Changes Summary + - Total commits since last release: ${output.total_commits} + - New features: ${output.feat_count} + - Bug fixes: ${output.fix_count} + - Breaking changes: ${output.breaking_count} + + ### Pre-Release Checklist + - [ ] Update CHANGELOG.md with release notes + - [ ] Review all changes since last release + - [ ] Ensure all tests are passing + - [ ] Verify version number is correct + + ### After Merge + When this PR is merged to \`main\`: + 1. Version **${output.pr_version}** will be automatically tagged + 2. A GitHub Release will be created + 3. DMG file will be built and attached to the release + + ### Note + If a tag for v${output.pr_version} already exists, the release will be skipped. + `; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + - name: Post PR comment for hotfix + if: github.base_ref == 'main' && startsWith(github.head_ref, 'hotfix/') + uses: actions/github-script@v7 + with: + script: | + const output = ${{ toJson(steps.analyze.outputs) }}; + + const body = `## 🚨 Hotfix Helper + + ### Version Update + - Current stable version: **${output.current_version}** + - Hotfix version: **${output.pr_version}** + ${output.current_version === output.pr_version ? '- ❌ **Version needs to be updated**' : '- ✅ Version has been updated'} + + ### Hotfix Checklist + - [ ] Version has been incremented + - [ ] CHANGELOG.md updated with hotfix details + - [ ] Critical issue has been fixed + - [ ] Minimal changes only (no new features) + + ### After Merge + 1. This hotfix will be released immediately + 2. Remember to merge this hotfix back to \`develop\` + + \`\`\`bash + # After this PR is merged, sync develop: + git checkout develop + git pull origin develop + git merge origin/main + git push origin develop + \`\`\` + `; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index 60c8dff..2998ce4 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -73,6 +73,7 @@ disabled_rules: - trailing_comma - force_cast - force_try + - type_body_length # Rule configuration line_length: diff --git a/CHANGELOG.md b/CHANGELOG.md index b8fcd2e..08e2eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,167 @@ All notable changes to this project will be documented in this file. + + + + + + + + + + + + + + + + + +## [0.7.15] - 2025-07-20 + +### Added +- アップデートチャンネル機能(安定版/開発版の選択) +- 各チャンネルの最新バージョン表示 +- Debug版でのアップデート設定UI表示 + +### Changed +- アップデート配信をGitHub Pagesベースに変更 +- 開発版チャンネルの説明文を「releases only」に修正 + +### Fixed +- + +## [0.7.14] - 2025-07-06 + +### Fixed +- Staple notarization失敗を修正 + - Sparkle.framework内のすべてのバイナリ(Autoupdate、Sparkle、Updater.app、XPCServices)を個別に署名 + - アプリ全体の署名に--deepオプションを追加 + - 署名検証とデバッグ情報を強化 + +## [0.7.13] - 2025-07-06 + +### Fixed +- GitHub ActionsでのXcodeバージョン指定を修正 + - Xcode 16.0はmacOS 14ランナーで利用不可のため、16.2を使用 + - CI設定の一貫性を改善(環境変数方式で統一) + - テストカバレッジ生成条件を最新のXcodeバージョンに更新 + +## [0.7.12] - 2025-07-06 + +### Fixed +- CI/CDパイプラインのビルドエラーを修正 + - GitHub ActionsのmacOS-latestランナーでXcode 15.xが利用不可になったため、Xcode 16.0に更新 + - Sparkleフレームワークとツールを最新の2.7.1に統一 + +## [0.7.11] - 2025-07-06 + +### Added +- バージョン検証成功時に古い警告コメントを自動削除する機能 + +## [0.7.10] - 2025-07-06 + +### Fixed +- CI環境でのSparkle appcast生成エラーを修正 +- プライベートキーの改行問題を解決(echo -nを使用) +- generate-appcast.shスクリプトを削除してワークフローに直接記述 +- Sparkleを最新版(2.6.4)に更新 + +## [0.7.9] - 2025-07-06 + +### Fixed +- CI環境でSparkleの`sign_update`ツールが見つからない問題を修正 +- setup-sparkle GitHub Actionを導入してSparkleツールのセットアップを自動化 +- Sparkleを最新のセキュリティバージョン(2.6.2)に更新 + +## [0.7.8] - 2025-07-06 + +### Fixed +- Sparkle署名生成の非推奨フラグを修正 (generate-appcast.sh, sign-update.sh) + +## [0.7.7] - 2025-07-06 + +### Fixed +- Update /start-work slash command to properly execute bash commands instead of pre-execution format + +## [0.7.6] - 2025-07-06 + +### Fixed +- Add DMG_PATH environment variable for Sparkle appcast generation + +### Changed +- Update /bump-version command documentation to clarify default behavior + +## [0.7.5] - 2025-07-06 + +### Fixed +- Remove GITHUB_TOKEN from workflow_call secrets (reserved name conflict) + +## [0.7.4] - 2025-07-06 + +### Fixed +- CI/CD workflow syntax errors in release-common.yml (heredoc issue) +- YAML indentation issues in all workflow files +- Security warnings for github.head_ref usage in pr-validation.yml and version-helper.yml +- ShellCheck warnings for unquoted GITHUB_OUTPUT variables + +## [0.7.3] - 2025-07-06 + +### Added +- Common reusable workflow for unified release processes + +### Changed +- Simplified download instructions in README (removed unnecessary developer verification warnings) +- Unified release workflows - dev and stable now use the same workflow with parameters +- Both dev and stable releases now support Sparkle auto-update + +### Fixed +- Fixed EOF delimiter error in development release workflow changelog generation +- Simplified release-dev workflow by removing unnecessary job separation +- Fixed command injection vulnerability in certificate name extraction +- Added secure cleanup for certificate files using trap + +## [0.7.2] - 2025-07-06 + +### Added +- + +### Changed +- + +### Fixed +- Fixed code signing issue in release-dev workflow by adding proper keychain configuration +- Unified release workflows with reusable build-and-sign workflow +- Improved certificate handling and error logging in signing process + +## [0.7.1] - 2025-07-06 + +### Added +- + +### Changed +- + +### Fixed +- + +## [0.7.0] - 2025-07-06 + +### Added +- Sparkle framework integration for automatic updates +- Update settings UI in Settings tab +- EdDSA signature verification for secure updates +- Automatic appcast.xml generation in release workflow + +### Changed +- Refactored GitHub Actions workflows to remove duplicates +- Updated release workflows to support Sparkle appcast generation + +### Fixed +- SessionBlock.usagePercentage compatibility issue with latest codebase + + ## [Unreleased] ## [1.0.0] - TBD @@ -11,4 +172,4 @@ All notable changes to this project will be documented in this file. ## [0.1.8] - TBD ### Fixed - Semantic versioning workflow implementation -- Package.swift version comment handling \ No newline at end of file +- Package.swift version comment handling diff --git a/Info.plist b/Info.plist index f33df6c..c7f9ddd 100644 --- a/Info.plist +++ b/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.0.0-dev + 0.7.15 CFBundleVersion - 0.0.0-dev + 0.7.15 CcusageVersion 15.3.0 LSMinimumSystemVersion @@ -30,5 +30,13 @@ NSUserNotificationAlertStyle banner + SUFeedURL + https://github.com/K9i-0/ClaudeCodeMonitor/releases/latest/download/appcast.xml + SUPublicEDKey + HIY9t036dJwnFg9au6m1/J67nyCgIL4YsjCZxyL0Nbw= + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..75c3626 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "df074165274afaa39539c05d57b0832620775b11", + "version" : "2.7.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index 3a36c58..f8e9d51 100644 --- a/Package.swift +++ b/Package.swift @@ -13,21 +13,31 @@ let package = Package( targets: ["ClaudeCodeMonitor"] ) ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.7.1") + ], targets: [ .executableTarget( name: "ClaudeCodeMonitor", + dependencies: [ + .product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS])) + ], path: "Sources/ClaudeUsageMonitor", resources: [ .process("Resources/en.lproj"), .process("Resources/ja.lproj"), .process("Resources/Assets.xcassets"), .copy("AppIcon.icns") + ], + swiftSettings: [ + .define("SWIFT_PACKAGE") ] ), .testTarget( name: "ClaudeCodeMonitorTests", dependencies: ["ClaudeCodeMonitor"], - path: "Tests/ClaudeUsageMonitorTests" + path: "Tests/ClaudeUsageMonitorTests", + exclude: ["README.md"] ) ] ) \ No newline at end of file diff --git a/README.ja.md b/README.ja.md index 19e8f31..377fa78 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,12 +47,6 @@ macOSのメニューバーに常駐し、Claude Codeの使用状況をリアル 最新のリリースを[GitHub Releases](https://github.com/K9i-0/ClaudeCodeMonitor/releases/latest)からダウンロードしてください。 -**注意**: 初回起動時に「開発元を検証できません」と表示される場合: -1. 警告ダイアログで「キャンセル」をクリック -2. システム設定 → プライバシーとセキュリティを開く -3. ClaudeCodeMonitorの「このまま開く」をクリック -4. またはアプリを右クリックして「開く」を選択 - ## 📋 必要な環境 - macOS 13.0以上 diff --git a/README.md b/README.md index e3dec81..dde97bc 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,6 @@ This app wraps the [ccusage](https://github.com/ryoppippi/ccusage) CLI tool to p Download the latest release from [GitHub Releases](https://github.com/K9i-0/ClaudeCodeMonitor/releases/latest). -**Note**: On first launch, you may see "Cannot be opened because the developer cannot be verified": -1. Click "Cancel" on the warning dialog -2. Open System Settings → Privacy & Security -3. Click "Open Anyway" for ClaudeCodeMonitor -4. Or simply right-click the app and select "Open" - ## 📋 Requirements - macOS 13.0 or later diff --git a/Sources/ClaudeUsageMonitor/App/AppDelegate.swift b/Sources/ClaudeUsageMonitor/App/AppDelegate.swift index 770bc70..1cd0fab 100644 --- a/Sources/ClaudeUsageMonitor/App/AppDelegate.swift +++ b/Sources/ClaudeUsageMonitor/App/AppDelegate.swift @@ -2,141 +2,108 @@ import Cocoa import SwiftUI import Combine import UserNotifications +#if canImport(Sparkle) +import Sparkle +#endif +@MainActor class AppDelegate: NSObject, NSApplicationDelegate { + @IBOutlet weak var window: NSWindow! + private var statusItem: NSStatusItem! private var popover: NSPopover! private var eventMonitor: EventMonitor? private var usageMonitor: UsageMonitor! private var environmentCheckResult = EnvironmentCheckResult() private var isEnvironmentValid = false + private var cancellables = Set() + + // Sparkle configuration based on build type + #if canImport(Sparkle) + #if DEBUG + // Enable Sparkle in debug builds for testing update UI + private lazy var updaterController = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: self, userDriverDelegate: nil) + #else + private lazy var updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil) + #endif + #else + private let updaterController: AnyObject? = nil + #endif func applicationDidFinishLaunching(_ notification: Notification) { // Debug builds use different settings to avoid conflicts with release version #if DEBUG // This will be reflected in menu bar and other UI elements print("Running in DEBUG mode") + print("Sparkle is enabled for UI testing (auto-updates disabled)") #endif + + // Configure Sparkle update channel + configureUpdateChannel() // Perform synchronous environment check first environmentCheckResult = CommandExecutor.shared.checkEnvironmentSync() - isEnvironmentValid = environmentCheckResult.isClaudeCodeInstalled && environmentCheckResult.canExecuteCommands - - if isEnvironmentValid { - print("[AppDelegate] All requirements met") - setupMainInterface() - } else { - print("[AppDelegate] Environment setup required") - setupEnvironmentCheckInterface() - } - } - - @MainActor - private func setupMainInterface() { + isEnvironmentValid = environmentCheckResult.hasClaudeCode && + (environmentCheckResult.hasBun || environmentCheckResult.hasNode) + + // Always create UsageMonitor (it handles invalid environments internally) usageMonitor = UsageMonitor() - - // 通知機能は初回リリースでは無効化 - /* - // Setup notification center delegate - if Bundle.main.bundleIdentifier != nil { - UNUserNotificationCenter.current().delegate = NotificationManager.shared - } - */ - - // Fetch exchange rates on startup - Task { - await CurrencySettings.shared.fetchExchangeRates() - } - - // Hide all windows for menubar-only app - NSApp.windows.forEach { window in - window.close() + + // Set up the status item + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + button.action = #selector(togglePopover(_:)) } - - setupStatusItem() - - // Create popover - popover = NSPopover() - popover.contentSize = NSSize(width: 380, height: 480) - popover.behavior = .transient - popover.animates = true - popover.contentViewController = NSHostingController( - rootView: ContentView() - .environmentObject(usageMonitor) - ) - - setupEventMonitor() + + // Set initial status bar title updateStatusBarTitle() - observeUsageDataChanges() - } - - @MainActor - private func setupEnvironmentCheckInterface() { - // Hide all windows for menubar-only app - NSApp.windows.forEach { window in - window.close() - } - - setupStatusItem() - - // Create popover with environment setup view + + // Configure popover popover = NSPopover() - popover.contentSize = NSSize(width: 480, height: 360) + popover.contentSize = NSSize(width: 480, height: 300) popover.behavior = .transient - popover.animates = true - popover.contentViewController = NSHostingController( - rootView: EnvironmentSetupView() - ) - - setupEventMonitor() - } - - private func setupStatusItem() { - // Create status bar item - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - - if let button = statusItem.button { - // SF Symbolsを使用した初期アイコン - if let image = NSImage(systemSymbolName: "hourglass", accessibilityDescription: "ClaudeCodeMonitor") { - let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium) - button.image = image.withSymbolConfiguration(config) - button.imagePosition = .imageLeading - } else { - button.title = "⏳" + popover.animates = false + + updatePopoverContent() + + // Set up event monitor for clicks outside popover + eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] event in + if let self = self, self.popover.isShown { + self.closePopover(event) } - button.action = #selector(togglePopover) - button.target = self } - } - - private func setupEventMonitor() { - // Monitor for clicks outside the popover - eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in - if let self = self, self.popover.isShown { - self.closePopover() + + // Subscribe to usage updates only if environment is valid + if isEnvironmentValid { + // Subscribe to usage data changes + usageMonitor.$usageData + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateStatusBarTitle() + } + .store(in: &cancellables) + + // Start monitoring + usageMonitor.startMonitoring() + + // Fetch exchange rates + Task { + await CurrencySettings.shared.fetchExchangeRates() } } } - - @MainActor - private func observeUsageDataChanges() { - guard let usageMonitor = usageMonitor else { return } - - // Update status bar title when usage data changes - updateStatusBarTitle() - - // Observe usage data changes - usageMonitor.$usageData - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateStatusBarTitle() - } - .store(in: &cancellables) + + private func updatePopoverContent() { + if isEnvironmentValid { + let contentView = ContentView() + .environmentObject(usageMonitor) + popover.contentViewController = NSHostingController(rootView: contentView) + } else { + let setupView = EnvironmentSetupView() + popover.contentViewController = NSHostingController(rootView: setupView) + } } - - private var cancellables = Set() - - @MainActor + private func updateStatusBarTitle() { guard let button = statusItem.button else { return } guard let usageMonitor = usageMonitor else { @@ -203,8 +170,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } } - - @MainActor + private func getStatusSymbol(percentage: Double) -> String { switch percentage { case 90...: @@ -222,7 +188,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - @MainActor private func getStatusColor(percentage: Double) -> NSColor { switch percentage { case 90...: @@ -237,80 +202,82 @@ class AppDelegate: NSObject, NSApplicationDelegate { return NSColor.systemGreen // 良好 } } - - @objc private func togglePopover() { + + @objc func togglePopover(_ sender: Any?) { if popover.isShown { - closePopover() + closePopover(sender) } else { - // Re-check environment when opening popover if not valid - if !isEnvironmentValid { - Task { @MainActor in - environmentCheckResult = await CommandExecutor.shared.checkEnvironment() - isEnvironmentValid = environmentCheckResult.isClaudeCodeInstalled && environmentCheckResult.canExecuteCommands - - if isEnvironmentValid { - // Environment is now valid, setup main interface - setupMainInterface() - // Update popover content - popover.contentViewController = NSHostingController( - rootView: ContentView() - .environmentObject(usageMonitor) - ) - } - showPopover() - } - } else { - Task { @MainActor in - showPopover() - } - } + showPopover(sender) } } - - @MainActor - func showPopover() { + + func showPopover(_ sender: Any?) { if let button = statusItem.button { - // Refresh data when opening popover (only if main interface is setup) - if let usageMonitor = usageMonitor { - usageMonitor.fetchUsageData() - } - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) eventMonitor?.start() } } - - func closePopover() { - popover.performClose(nil) + + func closePopover(_ sender: Any?) { + popover.performClose(sender) eventMonitor?.stop() } - + func applicationWillTerminate(_ notification: Notification) { - usageMonitor?.stopMonitoring() + usageMonitor.stopMonitoring() + } + + private func configureUpdateChannel() { + #if canImport(Sparkle) + let channel = UserDefaults.standard.updateChannel + print("Sparkle configured for \(channel.rawValue) channel: \(channel.appcastURL)") + #endif + } + + func updateChannelChanged(to newChannel: UpdateChannel) { + #if canImport(Sparkle) + let updater = updaterController.updater + + print("Sparkle channel changed to \(newChannel.rawValue): \(newChannel.appcastURL)") + + // Check for updates with new channel + updater.checkForUpdates() + #endif } } +// MARK: - SPUUpdaterDelegate +#if canImport(Sparkle) +extension AppDelegate: SPUUpdaterDelegate { + nonisolated func feedURLString(for updater: SPUUpdater) -> String? { + let channel = UserDefaults.standard.updateChannel + return channel.appcastURL + } +} +#endif + class EventMonitor { private var monitor: Any? private let mask: NSEvent.EventTypeMask private let handler: (NSEvent?) -> Void - + init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) { self.mask = mask self.handler = handler } - + deinit { stop() } - + func start() { monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) } - + func stop() { if let monitor = monitor { NSEvent.removeMonitor(monitor) self.monitor = nil } } -} +} \ No newline at end of file diff --git a/Sources/ClaudeUsageMonitor/Models/UpdateChannel.swift b/Sources/ClaudeUsageMonitor/Models/UpdateChannel.swift new file mode 100644 index 0000000..0f75992 --- /dev/null +++ b/Sources/ClaudeUsageMonitor/Models/UpdateChannel.swift @@ -0,0 +1,38 @@ +import Foundation + +enum UpdateChannel: String, CaseIterable { + case stable = "stable" + case dev = "dev" + + var displayName: String { + switch self { + case .stable: + return L10n.Update.stableChannel + case .dev: + return L10n.Update.devChannel + } + } + + var appcastURL: String { + switch self { + case .stable: + return "https://k9i-0.github.io/ClaudeCodeMonitor/appcast.xml" + case .dev: + return "https://k9i-0.github.io/ClaudeCodeMonitor/appcast-dev.xml" + } + } + + var description: String { + switch self { + case .stable: + return L10n.Update.stableChannelDescription + case .dev: + return L10n.Update.devChannelDescription + } + } + + static func fromVersion(_ version: String) -> UpdateChannel { + // If version contains "-dev", it's a dev channel + return version.contains("-dev") ? .dev : .stable + } +} \ No newline at end of file diff --git a/Sources/ClaudeUsageMonitor/Resources/en.lproj/Localizable.strings b/Sources/ClaudeUsageMonitor/Resources/en.lproj/Localizable.strings index faca32b..5cad70c 100644 --- a/Sources/ClaudeUsageMonitor/Resources/en.lproj/Localizable.strings +++ b/Sources/ClaudeUsageMonitor/Resources/en.lproj/Localizable.strings @@ -153,4 +153,22 @@ "share.history.title" = "Claude Code Usage 📊"; "share.history.today" = "Today: %@ tokens (%@)"; "share.history.month" = "Month: %@ tokens (%@)"; -"share.hashtags" = "#ClaudeCodeMonitor"; \ No newline at end of file +"share.hashtags" = "#ClaudeCodeMonitor"; + +// Updates +"update.settings" = "Update Settings"; +"update.checkForUpdates" = "Check for Updates"; +"update.automaticUpdates" = "Automatic Updates"; +"update.automaticUpdatesDescription" = "Check for updates automatically"; +"update.currentVersion" = "Current Version: %@"; +"update.checking" = "Checking for updates..."; +"update.upToDate" = "You're up to date!"; +"update.available" = "Update available"; +"update.failed" = "Failed to check for updates"; +"update.updateChannel" = "Update Channel"; +"update.stableChannel" = "Stable"; +"update.devChannel" = "Development"; +"update.stableChannelDescription" = "Stable releases only"; +"update.devChannelDescription" = "Development releases only"; +"update.recommended" = "(Recommended)"; +"update.latestVersion" = "Latest: %@"; diff --git a/Sources/ClaudeUsageMonitor/Resources/ja.lproj/Localizable.strings b/Sources/ClaudeUsageMonitor/Resources/ja.lproj/Localizable.strings index 20c4eca..6052af7 100644 --- a/Sources/ClaudeUsageMonitor/Resources/ja.lproj/Localizable.strings +++ b/Sources/ClaudeUsageMonitor/Resources/ja.lproj/Localizable.strings @@ -153,4 +153,22 @@ "history.dailyAverage" = "日次平均"; "history.peak" = "ピーク"; "history.today" = "今日"; -"history.perDay" = "日額"; \ No newline at end of file +"history.perDay" = "日額"; + +// Updates +"update.settings" = "アップデート設定"; +"update.checkForUpdates" = "アップデートを確認"; +"update.automaticUpdates" = "自動アップデート"; +"update.automaticUpdatesDescription" = "アップデートを自動的に確認する"; +"update.currentVersion" = "現在のバージョン: %@"; +"update.checking" = "アップデートを確認中..."; +"update.upToDate" = "最新の状態です!"; +"update.available" = "アップデートがあります"; +"update.failed" = "アップデート確認に失敗しました"; +"update.updateChannel" = "アップデートチャンネル"; +"update.stableChannel" = "安定版"; +"update.devChannel" = "開発版"; +"update.stableChannelDescription" = "安定版リリースのみ"; +"update.devChannelDescription" = "開発版リリースのみ"; +"update.recommended" = "(推奨)"; +"update.latestVersion" = "最新: %@"; diff --git a/Sources/ClaudeUsageMonitor/Services/CommandExecutor.swift b/Sources/ClaudeUsageMonitor/Services/CommandExecutor.swift index b1de8ef..a8c745d 100644 --- a/Sources/ClaudeUsageMonitor/Services/CommandExecutor.swift +++ b/Sources/ClaudeUsageMonitor/Services/CommandExecutor.swift @@ -236,4 +236,9 @@ struct EnvironmentCheckResult { var isBunInstalled = false var isNpxInstalled = false var canExecuteCommands = false + + // Convenience properties for backward compatibility + var hasClaudeCode: Bool { isClaudeCodeInstalled } + var hasBun: Bool { isBunInstalled } + var hasNode: Bool { isNpxInstalled } } diff --git a/Sources/ClaudeUsageMonitor/Utils/Localization.swift b/Sources/ClaudeUsageMonitor/Utils/Localization.swift index 1c260f1..8222b99 100644 --- a/Sources/ClaudeUsageMonitor/Utils/Localization.swift +++ b/Sources/ClaudeUsageMonitor/Utils/Localization.swift @@ -292,4 +292,28 @@ struct L10n { static var month: String { "share.history.month".localized } } } + + // Updates + struct Update { + static var settings: String { "update.settings".localized } + static var checkForUpdates: String { "update.checkForUpdates".localized } + static var automaticUpdates: String { "update.automaticUpdates".localized } + static var automaticUpdatesDescription: String { "update.automaticUpdatesDescription".localized } + static func currentVersion(version: String) -> String { + return "update.currentVersion".localized(with: version) + } + static var checking: String { "update.checking".localized } + static var upToDate: String { "update.upToDate".localized } + static var available: String { "update.available".localized } + static var failed: String { "update.failed".localized } + static var updateChannel: String { "update.updateChannel".localized } + static var stableChannel: String { "update.stableChannel".localized } + static var devChannel: String { "update.devChannel".localized } + static var stableChannelDescription: String { "update.stableChannelDescription".localized } + static var devChannelDescription: String { "update.devChannelDescription".localized } + static var recommended: String { "update.recommended".localized } + static func latestVersion(version: String) -> String { + return "update.latestVersion".localized(with: version) + } + } } diff --git a/Sources/ClaudeUsageMonitor/Utils/UserDefaults+UpdateChannel.swift b/Sources/ClaudeUsageMonitor/Utils/UserDefaults+UpdateChannel.swift new file mode 100644 index 0000000..60f97ab --- /dev/null +++ b/Sources/ClaudeUsageMonitor/Utils/UserDefaults+UpdateChannel.swift @@ -0,0 +1,22 @@ +import Foundation + +extension UserDefaults { + private enum Keys { + static let updateChannel = "UpdateChannel" + } + + var updateChannel: UpdateChannel { + get { + guard let rawValue = string(forKey: Keys.updateChannel), + let channel = UpdateChannel(rawValue: rawValue) else { + // Auto-detect based on current version + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + return UpdateChannel.fromVersion(version) + } + return channel + } + set { + set(newValue.rawValue, forKey: Keys.updateChannel) + } + } +} \ No newline at end of file diff --git a/Sources/ClaudeUsageMonitor/Views/Tabs/SettingsTabView.swift b/Sources/ClaudeUsageMonitor/Views/Tabs/SettingsTabView.swift index 84a879a..5399b9d 100644 --- a/Sources/ClaudeUsageMonitor/Views/Tabs/SettingsTabView.swift +++ b/Sources/ClaudeUsageMonitor/Views/Tabs/SettingsTabView.swift @@ -1,10 +1,36 @@ import SwiftUI +#if canImport(Sparkle) +import Sparkle +#endif struct SettingsTabView: View { @EnvironmentObject var monitor: UsageMonitor @StateObject private var languageSettings = LanguageSettings.shared @StateObject private var currencySettings = CurrencySettings.shared // @State private var notificationEnabled = Bundle.main.bundleIdentifier != nil ? NotificationManager.shared.isNotificationEnabled : false + @State private var latestVersion: String? + @State private var isCheckingForUpdates = false + @State private var updateCheckError: String? + @State private var latestStableVersion: String? + @State private var latestDevVersion: String? + #if canImport(Sparkle) + #if DEBUG + // Get updater from AppDelegate in debug builds + private var updater: SPUUpdater? { + if let appDelegate = NSApplication.shared.delegate as? AppDelegate, + let controller = appDelegate.value(forKey: "updaterController") as? SPUStandardUpdaterController { + return controller.updater + } + return nil + } + #else + private let updater = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil).updater + #endif + #else + private var updater: AnyObject? { + return nil + } + #endif let plans = [ ("Pro", "7,000 tokens/session", L10n.Plan.pro), @@ -190,6 +216,323 @@ struct SettingsTabView: View { } */ + // Show update settings in release builds or when testing Sparkle + #if DEBUG + Divider() + + // Update settings section + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Update.settings) + .font(.system(size: 16, weight: .semibold)) + + // Current version + let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" + HStack { + Text(L10n.Update.currentVersion(version: currentVersion)) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + #if DEBUG + Text("(Debug)") + .font(.system(size: 11)) + .foregroundColor(.orange) + #endif + } + + // Latest version info + if isCheckingForUpdates { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Checking for updates...") + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + } + } else if let error = updateCheckError { + HStack { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 11)) + .foregroundColor(.orange) + Text(error) + .font(.system(size: 12)) + .foregroundColor(.orange) + Spacer() + } + } else if let latest = latestVersion { + HStack { + Text("Latest: \(latest)") + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + } + } + + Spacer() + .frame(height: 4) + + // Update channel selection + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Update.updateChannel) + .font(.system(size: 14, weight: .medium)) + + ForEach(UpdateChannel.allCases, id: \.self) { channel in + Button(action: { + selectUpdateChannel(channel) + }) { + HStack { + Image(systemName: UserDefaults.standard.updateChannel == channel ? "checkmark.circle.fill" : "circle") + .foregroundColor(UserDefaults.standard.updateChannel == channel ? .accentColor : .secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(channel.displayName) + .font(.system(size: 14, weight: .medium)) + if channel == .stable { + Text(L10n.Update.recommended) + .font(.system(size: 11)) + .foregroundColor(.green) + } + } + + HStack(spacing: 8) { + Text(channel.description) + .font(.system(size: 12)) + .foregroundColor(.secondary) + + if channel == .stable && latestStableVersion != nil { + Text("•") + .foregroundColor(.secondary.opacity(0.5)) + Text(L10n.Update.latestVersion(version: latestStableVersion!)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } else if channel == .dev && latestDevVersion != nil { + Text("•") + .foregroundColor(.secondary.opacity(0.5)) + Text(L10n.Update.latestVersion(version: latestDevVersion!)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + } + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(UserDefaults.standard.updateChannel == channel ? Color.accentColor.opacity(0.1) : Color.clear) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.bottom, 8) + + // Check for updates button + Button(action: { + #if DEBUG + if let updater = updater { + updater.checkForUpdates() + } else { + // In Debug mode without updater, manually check appcast + checkLatestVersion() + } + #else + updater.checkForUpdates() + #endif + }) { + HStack { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 14)) + Text(L10n.Update.checkForUpdates) + .font(.system(size: 14)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + + // Automatic updates toggle + Toggle(isOn: .init( + get: { + #if DEBUG + updater?.automaticallyChecksForUpdates ?? false + #else + updater.automaticallyChecksForUpdates + #endif + }, + set: { + #if DEBUG + updater?.automaticallyChecksForUpdates = $0 + #else + updater.automaticallyChecksForUpdates = $0 + #endif + } + )) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Update.automaticUpdates) + .font(.system(size: 14)) + Text(L10n.Update.automaticUpdatesDescription) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + #else + Divider() + + // Update settings section + VStack(alignment: .leading, spacing: 12) { + Text(L10n.Update.settings) + .font(.system(size: 16, weight: .semibold)) + + // Current version + let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" + HStack { + Text(L10n.Update.currentVersion(version: currentVersion)) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + #if DEBUG + Text("(Debug)") + .font(.system(size: 11)) + .foregroundColor(.orange) + #endif + } + + // Latest version info + if isCheckingForUpdates { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Checking for updates...") + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + } + } else if let error = updateCheckError { + HStack { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 11)) + .foregroundColor(.orange) + Text(error) + .font(.system(size: 12)) + .foregroundColor(.orange) + Spacer() + } + } else if let latest = latestVersion { + HStack { + Text("Latest: \(latest)") + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + } + } + + Spacer() + .frame(height: 4) + + // Update channel selection + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Update.updateChannel) + .font(.system(size: 14, weight: .medium)) + + ForEach(UpdateChannel.allCases, id: \.self) { channel in + Button(action: { + selectUpdateChannel(channel) + }) { + HStack { + Image(systemName: UserDefaults.standard.updateChannel == channel ? "checkmark.circle.fill" : "circle") + .foregroundColor(UserDefaults.standard.updateChannel == channel ? .accentColor : .secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(channel.displayName) + .font(.system(size: 14, weight: .medium)) + if channel == .stable { + Text(L10n.Update.recommended) + .font(.system(size: 11)) + .foregroundColor(.green) + } + } + + HStack(spacing: 8) { + Text(channel.description) + .font(.system(size: 12)) + .foregroundColor(.secondary) + + if channel == .stable && latestStableVersion != nil { + Text("•") + .foregroundColor(.secondary.opacity(0.5)) + Text(L10n.Update.latestVersion(version: latestStableVersion!)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } else if channel == .dev && latestDevVersion != nil { + Text("•") + .foregroundColor(.secondary.opacity(0.5)) + Text(L10n.Update.latestVersion(version: latestDevVersion!)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + } + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(UserDefaults.standard.updateChannel == channel ? Color.accentColor.opacity(0.1) : Color.clear) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.bottom, 8) + + // Check for updates button + Button(action: { + if updater != nil { + updater.checkForUpdates() + } else { + // Fallback: manually check appcast + checkLatestVersion() + } + }) { + HStack { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 14)) + Text(L10n.Update.checkForUpdates) + .font(.system(size: 14)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(PlainButtonStyle()) + + // Automatic updates toggle + Toggle(isOn: .init( + get: { updater.automaticallyChecksForUpdates }, + set: { updater.automaticallyChecksForUpdates = $0 } + )) { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Update.automaticUpdates) + .font(.system(size: 14)) + Text(L10n.Update.automaticUpdatesDescription) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + #endif + #if DEBUG Divider() @@ -229,5 +572,119 @@ struct SettingsTabView: View { Spacer() } + .onAppear { + fetchLatestVersions() + } + } + + private func selectUpdateChannel(_ channel: UpdateChannel) { + UserDefaults.standard.updateChannel = channel + + // Notify AppDelegate to update Sparkle configuration + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.updateChannelChanged(to: channel) + } + } + + private func checkLatestVersion() { + isCheckingForUpdates = true + updateCheckError = nil + latestVersion = nil + + let channel = UserDefaults.standard.updateChannel + guard let url = URL(string: channel.appcastURL) else { + updateCheckError = "Invalid feed URL" + isCheckingForUpdates = false + return + } + + Task { + do { + let (data, _) = try await URLSession.shared.data(from: url) + + // Parse XML to find latest version + let parser = XMLParser(data: data) + let delegate = AppcastParserDelegate() + parser.delegate = delegate + + if parser.parse(), let version = delegate.latestVersion { + await MainActor.run { + self.latestVersion = version + self.isCheckingForUpdates = false + } + } else { + await MainActor.run { + self.updateCheckError = "Failed to parse update feed" + self.isCheckingForUpdates = false + } + } + } catch { + await MainActor.run { + self.updateCheckError = "Network error" + self.isCheckingForUpdates = false + } + } + } + } + + private func fetchLatestVersions() { + // Fetch stable version + Task { + await fetchLatestVersion(for: .stable) { version in + self.latestStableVersion = version + } + } + + // Fetch dev version + Task { + await fetchLatestVersion(for: .dev) { version in + self.latestDevVersion = version + } + } + } + + private func fetchLatestVersion(for channel: UpdateChannel, completion: @escaping (String?) -> Void) async { + guard let url = URL(string: channel.appcastURL) else { + completion(nil) + return + } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + let parser = XMLParser(data: data) + let delegate = AppcastParserDelegate() + parser.delegate = delegate + + if parser.parse(), let version = delegate.latestVersion { + await MainActor.run { + completion(version) + } + } + } catch { + await MainActor.run { + completion(nil) + } + } + } +} + +// Simple XML parser to extract version from appcast +private class AppcastParserDelegate: NSObject, XMLParserDelegate { + var latestVersion: String? + private var currentElement = "" + private var foundItem = false + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { + currentElement = elementName + + if elementName == "item" { + foundItem = true + } else if elementName == "enclosure" && foundItem { + // Extract version from sparkle:shortVersionString or sparkle:version + if let version = attributeDict["sparkle:shortVersionString"] ?? attributeDict["sparkle:version"] { + latestVersion = version + parser.abortParsing() // Stop after finding first item + } + } } } diff --git a/docs/RELEASE_STRATEGY.md b/docs/RELEASE_STRATEGY.md new file mode 100644 index 0000000..b835056 --- /dev/null +++ b/docs/RELEASE_STRATEGY.md @@ -0,0 +1,214 @@ +# ClaudeCodeMonitor Release Strategy + +## Overview + +This document outlines the release strategy for ClaudeCodeMonitor, including versioning, branching, and distribution channels. + +## Branch Strategy + +### 1. Main Branch (`main`) +- **Purpose**: Stable, production-ready code +- **Protection**: Protected branch with required PR reviews +- **CI/CD**: Automatically creates stable releases on push +- **Version**: Uses version from Info.plist as-is (e.g., `1.2.3`) + +### 2. Development Branch (`develop`) +- **Purpose**: Integration branch for features +- **Merges**: Feature branches merge here first +- **Testing**: Development builds distributed for testing +- **CI/CD**: Automatically creates dev releases on push +- **Version**: Adds `-dev` suffix (e.g., `1.2.3-dev`) + +### 3. Feature Branches (`feature/*`) +- **Purpose**: Individual feature development +- **Naming**: `feature/feature-name` (e.g., `feature/add-sparkle-auto-update`) +- **Lifecycle**: Created from `develop`, merged back to `develop` +- **Requirement**: Must update version in Info.plist before merging + +### 4. Hotfix Branches (`hotfix/*`) +- **Purpose**: Emergency fixes for production +- **Naming**: `hotfix/fix-description` +- **Process**: Created from `main`, merged to both `main` and `develop` +- **Requirement**: Must increment version and update CHANGELOG + +## Version Management + +### Version Update Rules +1. **feature → develop**: Version must be incremented in Info.plist +2. **develop → main**: Version remains the same (only removes `-dev` suffix) +3. **hotfix → main**: Version must be incremented + +### Version Format +- **Info.plist**: Always contains the base version (e.g., `1.2.3`) +- **Development builds**: Append `-dev` during build (e.g., `1.2.3-dev`) +- **Stable builds**: Use version as-is (e.g., `1.2.3`) + +### Development Versions +- **Purpose**: Testing and preview builds +- **Format**: `1.2.3-dev` +- **Distribution**: GitHub pre-releases + +## Release Process + +### 1. Feature Development Flow +``` +feature/xxx → develop → automatic dev release +``` + +1. Create feature branch from `develop` +2. Implement feature +3. **Update version in Info.plist** (required) +4. Create PR to `develop` +5. After merge, `1.2.3-dev` is automatically released + +### 2. Stable Release Flow +``` +develop → main → automatic stable release +``` + +1. Create PR from `develop` to `main` +2. Update CHANGELOG.md (required) +3. Version stays the same in Info.plist +4. After merge, `1.2.3` is automatically released + +### 3. Hotfix Flow +``` +main → hotfix/xxx → main + develop +``` + +1. Create hotfix branch from `main` +2. Fix critical issue +3. **Update version** (increment PATCH) +4. **Update CHANGELOG.md** +5. Merge to `main` first (automatic release) +6. Merge to `develop` to sync + +## CI/CD Configuration + +### GitHub Actions Workflows + +#### 1. Development Release (`release-dev.yml`) +- **Trigger**: Push to `develop` branch +- **Action**: Build and release with `-dev` suffix +- **Output**: `ClaudeCodeMonitor-1.2.3-dev.dmg` + +#### 2. Stable Release (`release-stable.yml`) +- **Trigger**: Push to `main` branch +- **Action**: Build and release stable version +- **Output**: `ClaudeCodeMonitor-1.2.3.dmg` +- **Note**: Skips if version tag already exists + +#### 3. PR Validation (`pr-validation.yml`) +- **Trigger**: PR to `develop` or `main` +- **Checks**: + - Version update for feature → develop + - CHANGELOG update for develop → main + - Conventional commit format (warning only) + +#### 4. Version Helper (`version-helper.yml`) +- **Trigger**: PR opened to `develop` or `main` +- **Action**: Posts helpful comment with: + - Current version info + - Commit analysis + - Update instructions + +#### 5. Build & Test (`build.yml`) +- **Trigger**: Push/PR to `main` or `develop` +- **Action**: Run swift build and tests + +## Sparkle Update Configuration + +### Update Channels + +#### 1. Stable Channel (Production) +- **Feed URL**: `https://your-domain.com/appcast.xml` +- **Versions**: Only stable releases (e.g., `1.2.3`) +- **Users**: Default for all users + +#### 2. Development Channel (Testing) +- **Feed URL**: `https://your-domain.com/appcast-dev.xml` +- **Versions**: Dev releases + stable releases +- **Users**: Opt-in for development builds +- **Setting**: Toggle in app preferences + +### Channel Selection +```swift +// User can choose update channel in settings +enum UpdateChannel { + case stable // appcast.xml + case dev // appcast-dev.xml +} +``` + +## Distribution Strategy + +### 1. GitHub Releases +- **Stable**: Tagged releases on `main` +- **Beta**: Pre-release flag enabled +- **Assets**: DMG, release notes, checksums + +### 2. Homebrew Cask +- **Updates**: Only stable releases +- **Process**: Automated PR via GitHub Actions +- **Timing**: After successful release + +### 3. Direct Download +- **Website**: Link to latest GitHub release +- **Auto-update**: Via Sparkle + +## Testing Sparkle in Development + +### Environment Variable Method +```bash +# Enable Sparkle in debug build +TEST_SPARKLE=1 swift run + +# Or in Xcode +# Edit Scheme → Run → Arguments → Environment Variables +# Add: TEST_SPARKLE = 1 +``` + +This allows testing Sparkle functionality without creating release builds. + +## Recommended Workflow + +### For New Features +1. Create feature branch from `develop` +2. Implement and test locally +3. **Update version** using `./scripts/update-version.sh x.y.z` +4. Create PR to `develop` +5. After merge, dev version is automatically released +6. Test with dev build +7. When ready for stable, PR `develop` → `main` + +### For Urgent Fixes +1. Create hotfix from `main` +2. Fix and test +3. **Update version** (increment patch) +4. **Update CHANGELOG.md** +5. PR to `main` (automatic release on merge) +6. Merge to `develop` to sync + +### Version Update Helper Script +```bash +# Update version easily +./scripts/update-version.sh 1.2.3 + +# This will: +# - Update Info.plist +# - Add placeholder to CHANGELOG.md +# - Show git status +# - Suggest commit command +``` + +## Security Considerations + +### Code Signing +- **Development**: Ad-hoc signing for testing +- **Beta**: Developer ID for distribution +- **Production**: Developer ID + Notarization + +### Sparkle Security +- **EdDSA Key**: Required for secure updates +- **HTTPS**: Always use HTTPS for appcast +- **Verification**: Sparkle verifies signatures automatically \ No newline at end of file diff --git a/docs/sparkle-setup.md b/docs/sparkle-setup.md new file mode 100644 index 0000000..2c42628 --- /dev/null +++ b/docs/sparkle-setup.md @@ -0,0 +1,126 @@ +# Sparkle Setup Guide + +This guide explains how to set up Sparkle for automatic updates in ClaudeCodeMonitor. + +## Prerequisites + +- macOS with Homebrew installed +- Access to the GitHub repository secrets + +## Initial Setup (One-time only) + +### 1. Install Sparkle Tools + +```bash +# Download Sparkle +curl -L -o sparkle.tar.xz https://github.com/sparkle-project/Sparkle/releases/download/2.5.2/Sparkle-2.5.2.tar.xz +tar -xf sparkle.tar.xz + +# The tools will be in Sparkle.framework/Versions/Current/Resources/ +``` + +### 2. Generate EdDSA Key Pair + +```bash +# Navigate to Sparkle tools directory +cd Sparkle.framework/Versions/Current/Resources/ + +# Generate key pair +./generate_keys + +# This will output: +# - Public key (EdDSA) +# - Private key (EdDSA) +``` + +### 3. Configure Public Key + +1. Copy the public key from the output +2. Update `Info.plist`: + ```xml + SUPublicEDKey + YOUR_PUBLIC_KEY_HERE + ``` +3. Commit and push this change + +### 4. Configure Private Key + +1. Go to GitHub repository settings +2. Navigate to Settings → Secrets and variables → Actions +3. Create a new repository secret named `SPARKLE_PRIVATE_KEY` +4. Paste the private key (including the entire line) + +## How It Works + +### Release Process + +When a PR is merged to main: + +1. The release workflow creates a new version +2. Builds and signs the app +3. Creates a DMG file +4. If `SPARKLE_PRIVATE_KEY` is set: + - Generates EdDSA signature for the DMG + - Creates `appcast.xml` with update information + - Uploads both DMG and appcast.xml to GitHub Release + +### Update Check + +1. ClaudeCodeMonitor checks the appcast.xml URL periodically +2. If a new version is found, it verifies the EdDSA signature +3. Prompts user to download and install the update + +### appcast.xml Location + +The appcast.xml file is available at: +``` +https://github.com/K9i-0/ClaudeCodeMonitor/releases/latest/download/appcast.xml +``` + +## Security Notes + +- **Never commit the private key** to the repository +- The private key should only be stored in GitHub Secrets +- The public key is safe to commit and share +- EdDSA signatures ensure updates haven't been tampered with + +## Testing Updates + +To test the update mechanism: + +1. Build a test version with a lower version number +2. Run the app +3. Check for updates from Settings → Update Settings +4. Verify that the app detects the newer version + +## Troubleshooting + +### "No updates available" when there should be + +1. Check that `SUFeedURL` in Info.plist is correct +2. Verify appcast.xml exists at the URL +3. Check Console.app for Sparkle-related errors + +### Signature verification failed + +1. Ensure the public key in Info.plist matches the one used to generate signatures +2. Verify the private key in GitHub Secrets is correct +3. Check that the DMG hasn't been modified after signing + +## Manual Key Generation (Alternative) + +If you need to generate keys manually: + +```bash +# Generate private key +openssl genpkey -algorithm ed25519 -out private_key.pem + +# Extract public key +openssl pkey -in private_key.pem -pubout -out public_key.pem + +# Convert to Sparkle format (base64) +cat private_key.pem | openssl base64 -A +cat public_key.pem | openssl base64 -A +``` + +Note: Use the Sparkle-provided `generate_keys` tool for best compatibility. \ No newline at end of file diff --git a/scripts/build-local.sh b/scripts/build-local.sh deleted file mode 100755 index 03dce39..0000000 --- a/scripts/build-local.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash - -# Build script for local development -# This creates a development build with the current git state - -echo "🔨 Building ClaudeCodeMonitor (Development Version)" - -# Get version information -if git describe --exact-match --tags HEAD 2>/dev/null; then - # Building from a tag - VERSION=$(git describe --exact-match --tags HEAD | sed 's/^v//') - echo "Building from tag: v$VERSION" -else - # Development build - COMMIT=$(git rev-parse --short HEAD) - BRANCH=$(git rev-parse --abbrev-ref HEAD) - VERSION="0.0.0-dev+$BRANCH.$COMMIT" - echo "Development build: $VERSION" -fi - -# Build the app -echo "Building Swift package..." -swift build -c release - -# Find the executable -EXECUTABLE_PATH=$(find .build -name ClaudeCodeMonitor -type f -perm +111 | grep -v '.dSYM' | grep release | head -1) - -if [ ! -f "$EXECUTABLE_PATH" ]; then - echo "❌ Failed to find executable" - exit 1 -fi - -echo "✅ Binary built at: $EXECUTABLE_PATH" - -# Create app bundle -echo "Creating app bundle..." -rm -rf ClaudeCodeMonitor.app -mkdir -p "ClaudeCodeMonitor.app/Contents/MacOS" -mkdir -p "ClaudeCodeMonitor.app/Contents/Resources" - -# Copy executable -cp "$EXECUTABLE_PATH" "ClaudeCodeMonitor.app/Contents/MacOS/" - -# Copy and update Info.plist -cp Info.plist "ClaudeCodeMonitor.app/Contents/" -/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "ClaudeCodeMonitor.app/Contents/Info.plist" -/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "ClaudeCodeMonitor.app/Contents/Info.plist" - -# Copy resource bundles -echo "Looking for resource bundles..." -find .build -name "*.bundle" -type d | grep release | while read bundle; do - echo "Found bundle: $bundle" - cp -R "$bundle" "ClaudeCodeMonitor.app/Contents/Resources/" -done - -# Copy app icon -if [ -f "Sources/ClaudeUsageMonitor/AppIcon.icns" ]; then - echo "Copying app icon..." - cp "Sources/ClaudeUsageMonitor/AppIcon.icns" "ClaudeCodeMonitor.app/Contents/Resources/" -elif [ -f "AppIcon.icns" ]; then - echo "Copying app icon from root..." - cp "AppIcon.icns" "ClaudeCodeMonitor.app/Contents/Resources/" -fi - -# Ad-hoc sign for local use -echo "Signing app bundle..." -codesign --force --deep --sign - "ClaudeCodeMonitor.app" - -echo "✅ Build complete!" -echo "" -echo "To run the app:" -echo " open ClaudeCodeMonitor.app" -echo "" -echo "Version: $VERSION" \ No newline at end of file diff --git a/scripts/build-release.sh b/scripts/build-release.sh deleted file mode 100755 index 2e41773..0000000 --- a/scripts/build-release.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/bash - -# Build script for ClaudeCodeMonitor release -# Creates a signed and notarized DMG for distribution - -set -e - -# Configuration -APP_NAME="ClaudeCodeMonitor" -BUNDLE_ID="com.k9i.claude-code-monitor" -VERSION="1.0.0" -BUILD_DIR=".build/release" -APP_PATH="$APP_NAME.app" -DMG_NAME="ClaudeCodeMonitor-$VERSION.dmg" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${GREEN}Building $APP_NAME v$VERSION for release...${NC}" - -# Clean previous builds -echo -e "${YELLOW}Cleaning previous builds...${NC}" -rm -rf "$BUILD_DIR" -rm -rf "$APP_PATH" -rm -f "$DMG_NAME" - -# Build in release mode -echo -e "${YELLOW}Building with Swift...${NC}" -swift build -c release - -# Create app bundle structure -echo -e "${YELLOW}Creating app bundle...${NC}" -mkdir -p "$APP_PATH/Contents/MacOS" -mkdir -p "$APP_PATH/Contents/Resources" - -# Copy executable -cp "$BUILD_DIR/ClaudeCodeMonitor" "$APP_PATH/Contents/MacOS/" - -# Copy Info.plist -cp Info.plist "$APP_PATH/Contents/" - -# Copy entitlements (for reference, not embedded) -cp ClaudeCodeMonitor.entitlements "$APP_PATH/Contents/" - -# Sign the app if not skipped -if [ -z "$SKIP_SIGNING" ]; then - echo -e "${YELLOW}Signing app...${NC}" - - # Use ad-hoc signing for now (replace with Developer ID for distribution) - codesign --force --deep --strict \ - --options runtime \ - --entitlements ClaudeCodeMonitor.entitlements \ - --sign - \ - "$APP_PATH" - - # Verify the signature - echo -e "${YELLOW}Verifying signature...${NC}" - codesign --verify --deep --strict --verbose=2 "$APP_PATH" -else - echo -e "${YELLOW}Skipping code signing (SKIP_SIGNING is set)${NC}" -fi - -# Create DMG -echo -e "${YELLOW}Creating DMG...${NC}" -mkdir -p dmg-content -cp -R "$APP_PATH" dmg-content/ - -# Create a simple DMG (for more advanced DMG with background image, use create-dmg tool) -hdiutil create -volname "$APP_NAME" \ - -srcfolder dmg-content \ - -ov -format UDZO \ - "$DMG_NAME" - -# Clean up temporary files -rm -rf dmg-content - -# Calculate checksums -echo -e "${YELLOW}Calculating checksums...${NC}" -shasum -a 256 "$DMG_NAME" > "$DMG_NAME.sha256" - -echo -e "${GREEN}Build complete!${NC}" -echo -e "${GREEN}Created: $DMG_NAME${NC}" -echo -e "${GREEN}SHA256: $(cat $DMG_NAME.sha256)${NC}" - -# Instructions for notarization (requires Developer ID) -echo -e "${YELLOW}" -echo "To notarize this app for distribution:" -echo "1. Sign with a Developer ID Application certificate" -echo "2. Submit for notarization: xcrun notarytool submit $DMG_NAME --wait" -echo "3. Staple the notarization: xcrun stapler staple $DMG_NAME" -echo -e "${NC}" \ No newline at end of file diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh deleted file mode 100755 index cc69646..0000000 --- a/scripts/bump-version.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# DEPRECATED: This script is kept for backward compatibility -# Use get-next-version.sh instead for tag-based versioning - -echo "WARNING: bump-version.sh is deprecated. Use get-next-version.sh instead." >&2 -echo "This script will be removed in a future version." >&2 - -# For backward compatibility, just call get-next-version.sh -VERSION_TYPE=${1:-patch} -exec "$(dirname "$0")/get-next-version.sh" "$VERSION_TYPE" \ No newline at end of file diff --git a/scripts/copy-icon.sh b/scripts/copy-icon.sh deleted file mode 100755 index daac298..0000000 --- a/scripts/copy-icon.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -# Copy app icon to build output - -if [ -f "${SRCROOT}/AppIcon.icns" ]; then - echo "Copying AppIcon.icns to ${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" - cp "${SRCROOT}/AppIcon.icns" "${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/" -fi \ No newline at end of file diff --git a/scripts/create-app-bundle.sh b/scripts/create-app-bundle.sh index 411757b..246b087 100755 --- a/scripts/create-app-bundle.sh +++ b/scripts/create-app-bundle.sh @@ -79,6 +79,10 @@ mkdir -p "$APP_PATH/Contents/"{MacOS,Resources} echo " Copying executable..." cp "$UNIVERSAL_PATH" "$APP_PATH/Contents/MacOS/ClaudeCodeMonitor" +# Add rpath for Frameworks directory +echo " Adding rpath for Frameworks..." +install_name_tool -add_rpath "@loader_path/../Frameworks" "$APP_PATH/Contents/MacOS/ClaudeCodeMonitor" + # Copy Info.plist and update version echo " Copying Info.plist..." cp Info.plist "$APP_PATH/Contents/Info.plist" @@ -87,6 +91,20 @@ echo " Updating version to $VERSION..." /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP_PATH/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$APP_PATH/Contents/Info.plist" +# Create Frameworks directory +mkdir -p "$APP_PATH/Contents/Frameworks" + +# Copy Sparkle.framework +echo " Looking for Sparkle.framework..." +SPARKLE_FRAMEWORK=$(find .build -path "*/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" -type d | head -1) +if [ -n "$SPARKLE_FRAMEWORK" ] && [ -d "$SPARKLE_FRAMEWORK" ]; then + echo " Found Sparkle.framework: $SPARKLE_FRAMEWORK" + cp -R "$SPARKLE_FRAMEWORK" "$APP_PATH/Contents/Frameworks/" + echo " Sparkle.framework copied" +else + echo " ⚠️ Sparkle.framework not found" +fi + # Copy resource bundles from arm64 build (they are identical across architectures) echo " Looking for resource bundles..." find .build -path "*arm64*/release/*.bundle" -type d | while read bundle; do diff --git a/scripts/generate-appcast.sh b/scripts/generate-appcast.sh new file mode 100644 index 0000000..c8c53d3 --- /dev/null +++ b/scripts/generate-appcast.sh @@ -0,0 +1,88 @@ +#!/bin/bash +set -euo pipefail + +# This script demonstrates how to properly generate separate appcast files +# for stable and dev channels + +SPARKLE_VERSION="2.7.1" +GITHUB_REPO="${GITHUB_REPOSITORY:-K9i-0/ClaudeCodeMonitor}" +PRIVATE_KEY_FILE="${1:-}" + +if [ -z "$PRIVATE_KEY_FILE" ]; then + echo "Usage: $0 " + exit 1 +fi + +# Download Sparkle tools if not present +if [ ! -f "sparkle/bin/generate_appcast" ]; then + echo "Downloading Sparkle tools..." + mkdir -p sparkle + cd sparkle + curl -Lo sparkle.tar.xz "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + tar xzf sparkle.tar.xz + cd .. +fi + +# Fetch all releases from GitHub +echo "Fetching releases from GitHub..." +gh release list --limit 100 --json tagName,isDraft,isPrerelease,createdAt | jq -r '.[] | select(.isDraft == false) | .tagName' > all-tags.txt + +# Create directories for each channel +mkdir -p appcast-stable appcast-dev + +# Download DMGs for stable releases (no -dev suffix) +echo "Processing stable releases..." +while IFS= read -r tag; do + VERSION="${tag#v}" + if [[ ! "$VERSION" =~ -dev$ ]]; then + DMG_NAME="ClaudeCodeMonitor-${VERSION}.dmg" + DMG_URL="https://github.com/${GITHUB_REPO}/releases/download/${tag}/${DMG_NAME}" + + if [ ! -f "appcast-stable/${DMG_NAME}" ]; then + echo " Downloading ${DMG_NAME}..." + curl -L -o "appcast-stable/${DMG_NAME}" "$DMG_URL" || echo " Failed to download ${DMG_NAME}" + fi + + # Also include in dev channel + if [ ! -f "appcast-dev/${DMG_NAME}" ]; then + cp "appcast-stable/${DMG_NAME}" "appcast-dev/${DMG_NAME}" 2>/dev/null || true + fi + fi +done < all-tags.txt + +# Download DMGs for dev releases (-dev suffix) +echo "Processing dev releases..." +while IFS= read -r tag; do + VERSION="${tag#v}" + if [[ "$VERSION" =~ -dev$ ]]; then + DMG_NAME="ClaudeCodeMonitor-${VERSION}.dmg" + DMG_URL="https://github.com/${GITHUB_REPO}/releases/download/${tag}/${DMG_NAME}" + + if [ ! -f "appcast-dev/${DMG_NAME}" ]; then + echo " Downloading ${DMG_NAME}..." + curl -L -o "appcast-dev/${DMG_NAME}" "$DMG_URL" || echo " Failed to download ${DMG_NAME}" + fi + fi +done < all-tags.txt + +# Generate appcast.xml for stable channel +echo "Generating appcast.xml for stable channel..." +DOWNLOAD_URL_PREFIX="https://github.com/${GITHUB_REPO}/releases/download/" +./sparkle/bin/generate_appcast \ + --ed-key-file "$PRIVATE_KEY_FILE" \ + --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ + -o appcast.xml \ + appcast-stable/ + +# Generate appcast-dev.xml for dev channel +echo "Generating appcast-dev.xml for dev channel..." +./sparkle/bin/generate_appcast \ + --ed-key-file "$PRIVATE_KEY_FILE" \ + --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ + -o appcast-dev.xml \ + appcast-dev/ + +# Clean up +rm -rf sparkle appcast-stable appcast-dev all-tags.txt + +echo "✅ Successfully generated appcast.xml and appcast-dev.xml" \ No newline at end of file diff --git a/scripts/get-next-version.sh b/scripts/get-next-version.sh deleted file mode 100755 index e7a96c0..0000000 --- a/scripts/get-next-version.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash - -# Get next version based on git tags -# Usage: ./get-next-version.sh [patch|minor|major] - -VERSION_TYPE=${1:-patch} - -# Get the latest tag (semantic version tags only) -LATEST_TAG=$(git tag -l "v*.*.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" | sort -V | tail -1) - -if [ -z "$LATEST_TAG" ]; then - # No tags found, start with v0.1.0 - echo "No semantic version tags found, starting with v0.1.0" >&2 - CURRENT_VERSION="0.0.0" -else - # Extract version from tag (remove 'v' prefix) - CURRENT_VERSION=${LATEST_TAG#v} - echo "Latest tag: $LATEST_TAG (version: $CURRENT_VERSION)" >&2 -fi - -# Parse version components -IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" -MAJOR="${VERSION_PARTS[0]:-0}" -MINOR="${VERSION_PARTS[1]:-0}" -PATCH="${VERSION_PARTS[2]:-0}" - -# Increment version based on type -case "$VERSION_TYPE" in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch) - PATCH=$((PATCH + 1)) - ;; - *) - echo "Error: Invalid version type. Use 'patch', 'minor', or 'major'" >&2 - exit 1 - ;; -esac - -NEW_VERSION="$MAJOR.$MINOR.$PATCH" -NEW_TAG="v$NEW_VERSION" - -# Check if the new tag already exists -if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then - echo "Error: Tag $NEW_TAG already exists!" >&2 - echo "Current tags:" >&2 - git tag -l "v*.*.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" | sort -V | tail -5 >&2 - exit 1 -fi - -# Output the new version (without 'v' prefix for version, with 'v' for tag) -echo "Next version: $NEW_VERSION" >&2 -echo "Next tag: $NEW_TAG" >&2 - -# Output for scripts (clean output) -if [ -n "$GITHUB_OUTPUT" ]; then - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "tag=$NEW_TAG" >> $GITHUB_OUTPUT -else - # For local testing, output just the version - echo "$NEW_VERSION" -fi \ No newline at end of file diff --git a/scripts/set-debug-bundle-id.sh b/scripts/set-debug-bundle-id.sh deleted file mode 100755 index c5c635f..0000000 --- a/scripts/set-debug-bundle-id.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# Set debug bundle ID for development builds - -if [ "$CONFIGURATION" = "Debug" ]; then - echo "Setting debug bundle ID..." - - # Backup original Info.plist - cp "$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Info.plist" "$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Info.plist.bak" - - # Replace bundle ID - /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.k9i.ClaudeCodeMonitor.debug" "$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Info.plist" - - # Replace app name - /usr/libexec/PlistBuddy -c "Set :CFBundleName ClaudeCodeMonitor-Debug" "$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Info.plist" - /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName ClaudeCodeMonitor-Debug" "$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Info.plist" - - echo "Debug bundle ID set to: com.k9i.ClaudeCodeMonitor.debug" -fi \ No newline at end of file diff --git a/scripts/sign-update.sh b/scripts/sign-update.sh new file mode 100755 index 0000000..626f0ce --- /dev/null +++ b/scripts/sign-update.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -euo pipefail + +# This script signs a release file with Sparkle's EdDSA signature + +# Check if the required arguments are provided +if [ $# -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +FILE_TO_SIGN="$1" + +# Check if the file exists +if [ ! -f "$FILE_TO_SIGN" ]; then + echo "Error: File not found: $FILE_TO_SIGN" + exit 1 +fi + +# Check if the required environment variable is set +if [ -z "${SPARKLE_PRIVATE_KEY:-}" ]; then + echo "Error: SPARKLE_PRIVATE_KEY environment variable is not set" + exit 1 +fi + +# Create temporary directory for Sparkle tools +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +# Download Sparkle tools if not available +SPARKLE_VERSION="2.5.2" +SPARKLE_TOOLS_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + +echo "Downloading Sparkle tools..." +curl -L -o "$TEMP_DIR/sparkle.tar.xz" "$SPARKLE_TOOLS_URL" +tar -xf "$TEMP_DIR/sparkle.tar.xz" -C "$TEMP_DIR" + +# Path to sign_update tool +SIGN_UPDATE="$TEMP_DIR/Sparkle.framework/Versions/Current/Resources/sign_update" + +if [ ! -f "$SIGN_UPDATE" ]; then + echo "Error: sign_update tool not found at $SIGN_UPDATE" + exit 1 +fi + +# Generate EdDSA signature +echo "Generating signature for $FILE_TO_SIGN..." +# Write private key to temporary file with secure permissions +PRIVATE_KEY_FILE="$TEMP_DIR/private_key.txt" +(umask 077 && echo "$SPARKLE_PRIVATE_KEY" > "$PRIVATE_KEY_FILE") +SIGNATURE=$("$SIGN_UPDATE" -f "$PRIVATE_KEY_FILE" -p "$FILE_TO_SIGN") +# Immediately remove the private key file after use +rm -f "$PRIVATE_KEY_FILE" + +if [ -z "$SIGNATURE" ]; then + echo "Error: Failed to generate signature" + exit 1 +fi + +echo "Signature: $SIGNATURE" + +# Export for use in other scripts +export SPARKLE_SIGNATURE="$SIGNATURE" \ No newline at end of file diff --git a/scripts/update-appcast.sh b/scripts/update-appcast.sh new file mode 100755 index 0000000..d0b466e --- /dev/null +++ b/scripts/update-appcast.sh @@ -0,0 +1,97 @@ +#!/bin/bash +set -euo pipefail + +# Script to update appcast files in gh-pages branch +# Usage: ./scripts/update-appcast.sh + +VERSION="${1:-}" +IS_DEV_BUILD="${2:-false}" +SPARKLE_PRIVATE_KEY="${SPARKLE_PRIVATE_KEY:-}" + +if [ -z "$VERSION" ]; then + echo "Usage: $0 [is_dev_build]" + exit 1 +fi + +echo "Updating appcast for version $VERSION (dev: $IS_DEV_BUILD)" + +# Save current branch +CURRENT_BRANCH=$(git branch --show-current) + +# Download Sparkle tools if needed +if [ ! -f "sparkle/bin/generate_appcast" ] && [ -n "$SPARKLE_PRIVATE_KEY" ]; then + echo "Downloading Sparkle tools..." + mkdir -p sparkle + cd sparkle + curl -Lo sparkle.tar.xz https://github.com/sparkle-project/Sparkle/releases/download/2.7.1/Sparkle-2.7.1.tar.xz + tar xzf sparkle.tar.xz + cd .. +fi + +# Create temp directory for appcast generation +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +# Copy DMG to temp directory +DMG_NAME="ClaudeCodeMonitor-${VERSION}.dmg" +if [ -f "$DMG_NAME" ]; then + cp "$DMG_NAME" "$TEMP_DIR/" +else + echo "Warning: $DMG_NAME not found in current directory" +fi + +# Checkout gh-pages branch +echo "Switching to gh-pages branch..." +git fetch origin gh-pages +git checkout gh-pages + +# Determine which appcast to update +if [ "$IS_DEV_BUILD" == "true" ]; then + APPCAST_FILE="appcast-dev.xml" +else + APPCAST_FILE="appcast.xml" +fi + +# Copy existing appcast to temp directory +if [ -f "$APPCAST_FILE" ]; then + cp "$APPCAST_FILE" "$TEMP_DIR/" +fi + +# Generate new appcast +if [ -n "$SPARKLE_PRIVATE_KEY" ] && [ -f "sparkle/bin/generate_appcast" ]; then + echo "Generating appcast with Sparkle..." + + # Create private key file + PRIVATE_KEY_FILE=$(mktemp) + (umask 077 && echo -n "$SPARKLE_PRIVATE_KEY" > "$PRIVATE_KEY_FILE") + + # Generate appcast + DOWNLOAD_URL_PREFIX="https://github.com/K9i-0/ClaudeCodeMonitor/releases/download/v${VERSION}/" + ./sparkle/bin/generate_appcast \ + --ed-key-file "$PRIVATE_KEY_FILE" \ + --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ + -o "$APPCAST_FILE" \ + "$TEMP_DIR/" + + rm -f "$PRIVATE_KEY_FILE" +else + echo "No Sparkle private key available, manual appcast update required" +fi + +# Commit and push changes +if git diff --quiet "$APPCAST_FILE"; then + echo "No changes to $APPCAST_FILE" +else + git add "$APPCAST_FILE" + git commit -m "Update $APPCAST_FILE for version $VERSION" + git push origin gh-pages + echo "Successfully updated $APPCAST_FILE" +fi + +# Return to original branch +git checkout "$CURRENT_BRANCH" + +# Clean up Sparkle tools +rm -rf sparkle + +echo "✅ Appcast update complete" \ No newline at end of file diff --git a/scripts/update-version.sh b/scripts/update-version.sh new file mode 100755 index 0000000..d5d1a29 --- /dev/null +++ b/scripts/update-version.sh @@ -0,0 +1,178 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_info() { + echo -e "${YELLOW}ℹ️ $1${NC}" +} + +# Check if version argument is provided +if [ $# -eq 0 ]; then + print_error "No version specified" + echo "Usage: $0 " + echo "Examples:" + echo " $0 1.2.3 # Set specific version" + echo " $0 patch # Increment patch version (0.7.0 → 0.7.1)" + echo " $0 minor # Increment minor version (0.7.0 → 0.8.0)" + echo " $0 major # Increment major version (0.7.0 → 1.0.0)" + exit 1 +fi + +VERSION_ARG=$1 + +# Get current version from Info.plist +CURRENT_VERSION=$(grep -A1 "CFBundleShortVersionString" Info.plist | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + +if [ -z "$CURRENT_VERSION" ]; then + print_error "Failed to get current version from Info.plist" + exit 1 +fi + +# Check if argument is an increment type +if [[ "$VERSION_ARG" =~ ^(patch|minor|major)$ ]]; then + # Parse current version + IFS='.' read -r major minor patch <<< "$CURRENT_VERSION" + + # Calculate new version based on increment type + case "$VERSION_ARG" in + "patch") + patch=$((patch + 1)) + ;; + "minor") + minor=$((minor + 1)) + patch=0 + ;; + "major") + major=$((major + 1)) + minor=0 + patch=0 + ;; + esac + + NEW_VERSION="${major}.${minor}.${patch}" + print_info "Incrementing $VERSION_ARG version: $CURRENT_VERSION → $NEW_VERSION" +else + # Use the provided version directly + NEW_VERSION=$VERSION_ARG + + # Validate version format (x.y.z) + if ! echo "$NEW_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + print_error "Invalid version format: $NEW_VERSION" + echo "Version must be in format x.y.z (e.g., 1.2.3)" + exit 1 + fi +fi + +print_info "Current version: $CURRENT_VERSION" +print_info "New version: $NEW_VERSION" + +# Update Info.plist +print_info "Updating Info.plist..." + +# Create a temporary file +TEMP_FILE=$(mktemp) + +# Update both CFBundleShortVersionString and CFBundleVersion +awk -v new_version="$NEW_VERSION" ' + /CFBundleShortVersionString<\/key>/ { + print + getline + sub(/.*<\/string>/, "" new_version "") + } + /CFBundleVersion<\/key>/ { + print + getline + sub(/.*<\/string>/, "" new_version "") + } + { print } +' Info.plist > "$TEMP_FILE" + +# Move temp file back +mv "$TEMP_FILE" Info.plist + +# Verify the update +UPDATED_VERSION=$(grep -A1 "CFBundleShortVersionString" Info.plist | tail -1 | sed -E 's/.*(.*)<\/string>.*/\1/') + +if [ "$UPDATED_VERSION" != "$NEW_VERSION" ]; then + print_error "Failed to update version in Info.plist" + exit 1 +fi + +print_success "Updated Info.plist to version $NEW_VERSION" + +# Update CHANGELOG.md if it exists +if [ -f "CHANGELOG.md" ]; then + print_info "Checking CHANGELOG.md..." + + # Check if version already exists in changelog + if grep -q "## \[$NEW_VERSION\]" CHANGELOG.md; then + print_info "Version $NEW_VERSION already exists in CHANGELOG.md" + else + print_info "Adding new version section to CHANGELOG.md..." + + # Get today's date + TODAY=$(date +%Y-%m-%d) + + # Create temporary file + TEMP_CHANGELOG=$(mktemp) + + # Find the position after the header and before the first version entry + awk -v version="$NEW_VERSION" -v date="$TODAY" ' + /^# Changelog/ { print; printed_new = 0; next } + /^## \[/ && !printed_new { + print "" + print "## [" version "] - " date + print "" + print "### Added" + print "- " + print "" + print "### Changed" + print "- " + print "" + print "### Fixed" + print "- " + print "" + printed_new = 1 + } + { print } + ' CHANGELOG.md > "$TEMP_CHANGELOG" + + mv "$TEMP_CHANGELOG" CHANGELOG.md + print_success "Added version $NEW_VERSION to CHANGELOG.md" + print_info "Please update the changelog entries before committing" + fi +else + print_info "No CHANGELOG.md found, skipping changelog update" +fi + +# Show git status +echo "" +print_info "Git status:" +git status --short Info.plist CHANGELOG.md + +# Suggest next steps +echo "" +print_success "Version updated successfully!" +echo "" +echo "Next steps:" +echo "1. Review the changes" +echo "2. Update CHANGELOG.md with actual changes (if needed)" +echo "3. Commit the changes:" +echo " git add Info.plist CHANGELOG.md" +echo " git commit -m \"chore: bump version to $NEW_VERSION\"" +echo "4. Push to update your PR" \ No newline at end of file