diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33bd9b3e..863ff7d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,30 +1,185 @@ -name: Gradle CI +name: Build on: - push: - branches: [ main ] pull_request: - branches: [ main ] + branches: [main] + workflow_dispatch: + inputs: + create_dev_build: + description: 'Create development build' + required: false + default: false + type: boolean + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true # Cancel previous builds jobs: - build: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run Tests + run: ./gradlew allTests --stacktrace --continue + + build-verification: + needs: test runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build All Modules + run: ./gradlew build --stacktrace + + - name: Build XCFramework + run: ./gradlew :umbrella:createXCFramework --stacktrace + + build-dev: + needs: [test, build-verification] + runs-on: macos-latest + if: github.event.inputs.create_dev_build == 'true' + outputs: + build-version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Generate Build Version + id: version + run: | + # Use timestamp + short SHA for dev builds + TIMESTAMP=$(date +%Y%m%d-%H%M%S) + SHORT_SHA=${GITHUB_SHA:0:7} + BUILD_VERSION="dev-$TIMESTAMP-$SHORT_SHA" + echo "version=$BUILD_VERSION" >> $GITHUB_OUTPUT + echo "đŸ“Ļ Build version: $BUILD_VERSION" + + - name: Build XCFramework + run: ./gradlew :umbrella:createXCFramework + + - name: Prepare Distribution + run: | + cd umbrella/build/XCFrameworks/release + zip -r QuranSync-${{ steps.version.outputs.version }}.xcframework.zip QuranSync.xcframework + + - name: Calculate Checksum + id: checksum + run: | + CHECKSUM=$(swift package compute-checksum umbrella/build/XCFrameworks/release/QuranSync-${{ steps.version.outputs.version }}.xcframework.zip) + echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT + + - name: Update Package.swift for Dev Build + run: | + # Update Package.swift directly with dev build info + sed -i '' "s/{VERSION}/${{ steps.version.outputs.version }}/g" Package.swift + sed -i '' "s/{CHECKSUM_TO_BE_REPLACED_BY_CI}/${{ steps.checksum.outputs.checksum }}/g" Package.swift + + - name: Commit Package.swift for Dev Build + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Package.swift + git commit -m "chore: update Package.swift for dev build ${{ steps.version.outputs.version }}" + git push origin main + + - name: Create Development Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.version.outputs.version }} + name: "Development Build ${{ steps.version.outputs.version }}" + prerelease: true + files: | + umbrella/build/XCFrameworks/release/QuranSync-${{ steps.version.outputs.version }}.xcframework.zip + body: | + ## 🚧 Development Build + + **Commit:** ${{ github.sha }} + **Branch:** ${{ github.ref_name }} + **Checksum:** `${{ steps.checksum.outputs.checksum }}` + + ### Usage (SPM) + Package.swift has been automatically updated for this dev build: + ```swift + .package(url: "https://github.com/quran/mobile-sync", exact: "${{ steps.version.outputs.version }}") + ``` + + ### Alternative: Manual Binary Target + If you prefer manual control: + ```swift + .binaryTarget( + name: "QuranSync", + url: "https://github.com/quran/mobile-sync/releases/download/${{ steps.version.outputs.version }}/QuranSync-${{ steps.version.outputs.version }}.xcframework.zip", + checksum: "${{ steps.checksum.outputs.checksum }}" + ) + ``` + + ### âš ī¸ Important Warnings + - **This is a development build** - use stable releases for production + - **Dev builds may be deleted without notice** - we only keep the 3 most recent + - **Package.swift will be overwritten** by the next dev build or release + - **No stability guarantees** - APIs may change between dev builds + - **For testing only** - not recommended for App Store submissions + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + cleanup-old-dev-builds: + needs: build-dev + runs-on: ubuntu-latest + if: github.event.inputs.create_dev_build == 'true' steps: - - uses: actions/checkout@v5 - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - java-version: '17' - distribution: 'temurin' - cache: gradle - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - - name: Clean project - run: ./gradlew clean - - - name: Run tests - run: ./gradlew allTests --stacktrace --continue \ No newline at end of file + - name: Cleanup Old Dev Builds + uses: actions/github-script@v7 + with: + script: | + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + + // Keep last 3 dev builds, delete older ones + const devBuilds = releases + .filter(release => release.tag_name.startsWith('dev-')) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .slice(3); // Skip first 3 (keep them) + + for (const release of devBuilds) { + console.log(`Deleting old dev build: ${release.tag_name}`); + await github.rest.repos.deleteRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..abb83ac9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,176 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version_bump: + description: 'Version bump type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + prerelease: + description: 'Is this a prerelease?' + required: false + default: false + type: boolean + +concurrency: + group: release + cancel-in-progress: false # Don't cancel releases + +jobs: + release: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Java + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Get Current Version + id: current_version + run: | + # Get latest tag, default to 0.0.0 if none exists + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + CURRENT_VERSION=${LATEST_TAG#v} # Remove 'v' prefix + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "📋 Current version: $CURRENT_VERSION" + + - name: Calculate Next Version + id: next_version + run: | + CURRENT="${{ steps.current_version.outputs.version }}" + BUMP="${{ github.event.inputs.version_bump }}" + + # Split version into parts + IFS='.' read -ra VERSION_PARTS <<< "$CURRENT" + MAJOR=${VERSION_PARTS[0]:-0} + MINOR=${VERSION_PARTS[1]:-0} + PATCH=${VERSION_PARTS[2]:-0} + + # Calculate new version based on bump type + case $BUMP 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" + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "🚀 Next version: $NEW_VERSION" + + - name: Run Tests + run: ./gradlew allTests + + - name: Build XCFramework + run: ./gradlew :umbrella:createXCFramework + + - name: Prepare Distribution + run: | + cd umbrella/build/XCFrameworks/release + zip -r QuranSync.xcframework.zip QuranSync.xcframework + + - name: Calculate Checksum + id: checksum + run: | + CHECKSUM=$(swift package compute-checksum umbrella/build/XCFrameworks/release/QuranSync.xcframework.zip) + echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT + + - name: Update Package.swift + run: | + sed -i '' "s/{VERSION}/v${{ steps.next_version.outputs.version }}/g" Package.swift + sed -i '' "s/{CHECKSUM_TO_BE_REPLACED_BY_CI}/${{ steps.checksum.outputs.checksum }}/g" Package.swift + + - name: Generate Release Notes + id: release_notes + run: | + # Get commits since last release + LAST_TAG="${{ steps.current_version.outputs.version }}" + if [ "$LAST_TAG" = "0.0.0" ]; then + COMMITS=$(git log --pretty=format:"- %s" --reverse) + else + COMMITS=$(git log v$LAST_TAG..HEAD --pretty=format:"- %s" --reverse) + fi + + # Create release notes + cat > release_notes.md << EOF + ## What's Changed + + $COMMITS + + ## Installation + + ### Swift Package Manager + Add to your \`Package.swift\`: + \`\`\`swift + dependencies: [ + .package(url: "https://github.com/quran/mobile-sync", from: "${{ steps.next_version.outputs.version }}") + ] + \`\`\` + + ### Xcode + 1. File → Add Package Dependencies + 2. Enter: \`https://github.com/quran/mobile-sync\` + 3. Select version: \`${{ steps.next_version.outputs.version }}\` + EOF + + - name: Commit Package.swift Updates + run: | + git add Package.swift + git commit -m "chore: update Package.swift for v${{ steps.next_version.outputs.version }}" || exit 0 + + - name: Create Tag + run: | + git tag -a "v${{ steps.next_version.outputs.version }}" -m "Release v${{ steps.next_version.outputs.version }}" + + - name: Push Changes + run: | + git push origin main + git push origin "v${{ steps.next_version.outputs.version }}" + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: "v${{ steps.next_version.outputs.version }}" + name: "QuranSync v${{ steps.next_version.outputs.version }}" + prerelease: ${{ github.event.inputs.prerelease }} + files: | + umbrella/build/XCFrameworks/release/QuranSync.xcframework.zip + body_path: release_notes.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "✅ Released QuranSync v${{ steps.next_version.outputs.version }}" + echo "đŸˇī¸ Tag: v${{ steps.next_version.outputs.version }}" + echo "đŸ“Ļ Asset: QuranSync.xcframework.zip" + echo "🔗 Release: https://github.com/quran/mobile-sync/releases/tag/v${{ steps.next_version.outputs.version }}" diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..3329d62f --- /dev/null +++ b/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version:5.5 +import PackageDescription + +// Check environment variable for local development mode (defaults to false) +let useLocalBuild = ProcessInfo.processInfo.environment["QURAN_SYNC_LOCAL_BUILD"] == "true" + +let package = Package( + name: "QuranSync", + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: "QuranSync", + targets: ["QuranSync"] + ) + ], + targets: useLocalBuild ? [ + .binaryTarget( + name: "QuranSync", + path: "umbrella/build/XCFrameworks/release/QuranSync.xcframework" + ) + ] : [ + .binaryTarget( + name: "QuranSync", + url: "https://github.com/quran/mobile-sync/releases/download/{VERSION}/QuranSync.xcframework.zip", + checksum: "{CHECKSUM_TO_BE_REPLACED_BY_CI}" + ) + ] +) diff --git a/docs/RELEASE_WORKFLOW.md b/docs/RELEASE_WORKFLOW.md new file mode 100644 index 00000000..ac71493a --- /dev/null +++ b/docs/RELEASE_WORKFLOW.md @@ -0,0 +1,150 @@ +# Release Workflow Guide - QuranSync + +## How Releases Work + +### 🔄 Development Builds (Automatic) +- **Trigger**: Every push to `main` branch +- **Tag Format**: `dev-YYYYMMDD-HHMMSS-SHORTSHA` (e.g., `dev-20240315-143022-abc1234`) +- **Package.swift**: Automatically updated with dev build info +- **Cancellation**: New builds cancel previous ones +- **Cleanup**: Only keeps 5 most recent dev builds +- **Usage**: For testing latest changes with standard SPM integration + +### 🚀 Stable Releases (Manual) +- **Trigger**: Manual workflow dispatch +- **Tag Format**: `v1.2.3` (semantic versioning) +- **No cancellation**: Releases run to completion +- **Persistence**: Stable releases are never auto-deleted + +## Triggering Releases + +### Option 1: GitHub Web Interface +1. Go to **Actions** tab in GitHub +2. Click **Release** workflow +3. Click **Run workflow** button +4. Choose: + - **Branch**: `main` (default) + - **Version bump**: `patch`, `minor`, or `major` + - **Prerelease**: `true` or `false` + +### Option 2: GitHub CLI (Recommended) +```bash +# Install GitHub CLI if needed +brew install gh +gh auth login + +# Trigger releases +gh workflow run release.yml -f version_bump=patch +gh workflow run release.yml -f version_bump=minor -f prerelease=true +gh workflow run release.yml -f version_bump=major +``` + +### Option 3: Helper Script (Easiest) +```bash +# Use our helper script +./scripts/release.sh patch # 1.0.0 → 1.0.1 +./scripts/release.sh minor # 1.0.0 → 1.1.0 +./scripts/release.sh major # 1.0.0 → 2.0.0 + +# Check status +./scripts/release.sh status # View releases and workflow runs +./scripts/release.sh cancel # Cancel running releases if needed +``` + +## What Happens During Builds/Releases + +### Development Build Steps (Automatic on main push): +1. **Testing**: Runs full test suite (`./gradlew allTests`) +2. **Building**: Creates XCFramework (`./gradlew :umbrella:createXCFramework`) +3. **Package Update**: Updates `Package.swift` with dev build version and checksum +4. **Git Operations**: + - Commits Package.swift changes + - Pushes updated Package.swift to main + - Creates dev build tag (e.g., `dev-20240315-143022-abc1234`) +5. **Release Creation**: + - Creates GitHub prerelease with dev build + - Uploads XCFramework zip file + - Includes checksum in release notes + +### Stable Release Steps (Manual trigger): +1. **Version Calculation**: Bumps version based on current git tags +2. **Testing**: Runs full test suite (`./gradlew allTests`) +3. **Building**: Creates XCFramework (`./gradlew :umbrella:createXCFramework`) +4. **Package Update**: Updates `Package.swift` with stable version and checksum +5. **Git Operations**: + - Commits Package.swift changes + - Creates new git tag (e.g., `v1.2.3`) + - Pushes to repository +6. **Release Creation**: + - Creates GitHub release with generated notes + - Uploads XCFramework zip file + - Marks as prerelease if specified + +### The Bot Email Explained +```yaml +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +``` +This is GitHub's official bot email address. When the workflow commits Package.swift changes, it shows up in git history as coming from `github-actions[bot]` instead of a real user account. + +## Version Bumping Logic + +The release workflow automatically determines the next version: + +```bash +# Current version: v1.2.3 + +./scripts/release.sh patch # → v1.2.4 (bug fixes) +./scripts/release.sh minor # → v1.3.0 (new features, backward compatible) +./scripts/release.sh major # → v2.0.0 (breaking changes) +``` + +## Development Workflow Examples + +### Daily Development +```bash +# 1. Work on features, merge PRs to main +git checkout -b feature/new-sync-algorithm +# ... make changes ... +git push origin feature/new-sync-algorithm +# ... create PR, merge to main ... + +# 2. Dev build automatically created: dev-20240315-143022-abc1234 + +# 3. Test in iOS project using dev build +./scripts/dev-setup.sh list-dev # See available builds +./scripts/dev-setup.sh dev dev-20240315-143022-abc1234 + +# Or use standard SPM (Package.swift is auto-updated): +# .package(url: "https://github.com/quran/mobile-sync", exact: "dev-20240315-143022-abc1234") + +# 4. When ready for stable release +./scripts/release.sh patch # Creates v1.0.1 +``` + +### Release Cadence Suggestions +- **Patch releases**: Bug fixes, small improvements (weekly/bi-weekly) +- **Minor releases**: New features, API additions (monthly/quarterly) +- **Major releases**: Breaking changes, major rewrites (quarterly/yearly) + +## Troubleshooting + +### Release Failed? +```bash +./scripts/release.sh status # Check what happened +gh run view [RUN_ID] --log # View detailed logs +``` + +### Need to Cancel Release? +```bash +./scripts/release.sh cancel # Stops running releases +``` + +### Wrong Version Released? +- You can create a new release immediately +- Old releases are never auto-deleted +- Consider using prerelease flag for testing + +### Dev Build Missing? +- Check if build failed: Go to Actions tab +- Older dev builds are auto-deleted (only 5 kept) +- Use `./scripts/dev-setup.sh list-dev` to see available builds \ No newline at end of file diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..98ed08d8 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# Release helper script for QuranSync + +set -e + +REPO="quran/mobile-sync" + +case "$1" in + "patch"|"minor"|"major") + BUMP_TYPE="$1" + PRERELEASE="${2:-false}" + + echo "🚀 Triggering $BUMP_TYPE release..." + + if [ "$PRERELEASE" = "true" ]; then + echo "âš ī¸ This will be marked as a prerelease" + fi + + # Use GitHub CLI to trigger workflow + gh workflow run release.yml \ + -f version_bump="$BUMP_TYPE" \ + -f prerelease="$PRERELEASE" + + echo "✅ Release workflow triggered!" + echo "🔗 Monitor progress: https://github.com/$REPO/actions" + ;; + + "dev") + echo "🚧 Triggering development build..." + echo "âš ī¸ This will create a dev build and update Package.swift" + echo "đŸ“Ļ Only the 3 most recent dev builds are kept" + + # Use GitHub CLI to trigger workflow + gh workflow run build.yml \ + -f create_dev_build="true" + + echo "✅ Development build workflow triggered!" + echo "🔗 Monitor progress: https://github.com/$REPO/actions" + ;; + + "status") + echo "📊 Recent releases:" + gh release list --limit 10 + echo "" + echo "đŸ—ī¸ Current workflow runs:" + gh run list --workflow=release.yml --limit 5 + ;; + + "dev-builds") + echo "🚧 Recent dev builds:" + gh release list --limit 10 | grep "dev-" || echo "No dev builds found" + ;; + + "cancel") + echo "âšī¸ Cancelling running release workflows..." + RUNS=$(gh run list --workflow=release.yml --status=in_progress --json databaseId --jq '.[].databaseId') + + if [ -z "$RUNS" ]; then + echo "No running release workflows found" + else + for RUN_ID in $RUNS; do + echo "Cancelling run $RUN_ID..." + gh run cancel $RUN_ID + done + echo "✅ Cancelled running workflows" + fi + ;; + + *) + echo "Usage: $0 {patch|minor|major|dev|status|dev-builds|cancel}" + echo "" + echo "Commands:" + echo " patch - Create a patch release (1.0.0 → 1.0.1)" + echo " minor - Create a minor release (1.0.0 → 1.1.0)" + echo " major - Create a major release (1.0.0 → 2.0.0)" + echo " dev - Create a development build" + echo " status - Show recent releases and workflow status" + echo " dev-builds - Show recent development builds" + echo " cancel - Cancel running release workflows" + echo "" + echo "Examples:" + echo " $0 patch # Normal patch release" + echo " $0 minor true # Minor prerelease" + echo " $0 major # Major release" + echo " $0 dev # Development build" + echo "" + echo "Prerequisites:" + echo " - Install GitHub CLI: brew install gh" + echo " - Authenticate: gh auth login" + exit 1 + ;; +esac \ No newline at end of file diff --git a/umbrella/build.gradle.kts b/umbrella/build.gradle.kts index 68cca094..c8cbb837 100644 --- a/umbrella/build.gradle.kts +++ b/umbrella/build.gradle.kts @@ -1,3 +1,7 @@ +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import javax.inject.Inject + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.vanniktech.maven.publish) @@ -10,7 +14,7 @@ kotlin { iosSimulatorArm64() ).forEach { iosTarget -> iosTarget.binaries.framework { - baseName = "Shared" + baseName = "QuranSync" isStatic = true export(projects.syncengine) @@ -28,6 +32,50 @@ kotlin { } } +// Task to create XCFramework +abstract class CreateXCFrameworkTask : DefaultTask() { + @get:InputDirectory + abstract val arm64Framework: DirectoryProperty + + @get:InputDirectory + abstract val simulatorFramework: DirectoryProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @get:Inject + abstract val execOperations: ExecOperations + + @TaskAction + fun createXCFramework() { + val outputFramework = outputDirectory.file("QuranSync.xcframework").get().asFile + + // Clean existing XCFramework if it exists + if (outputFramework.exists()) { + outputFramework.deleteRecursively() + } + + outputDirectory.get().asFile.mkdirs() + + execOperations.exec { + commandLine("xcodebuild", "-create-xcframework", + "-framework", arm64Framework.get().asFile.absolutePath, + "-framework", simulatorFramework.get().asFile.absolutePath, + "-output", outputFramework.absolutePath + ) + } + } +} + +tasks.register("createXCFramework") { + dependsOn("linkReleaseFrameworkIosArm64") + dependsOn("linkReleaseFrameworkIosSimulatorArm64") + + arm64Framework.set(layout.buildDirectory.dir("bin/iosArm64/releaseFramework/QuranSync.framework")) + simulatorFramework.set(layout.buildDirectory.dir("bin/iosSimulatorArm64/releaseFramework/QuranSync.framework")) + outputDirectory.set(layout.buildDirectory.dir("XCFrameworks/release")) +} + mavenPublishing { publishToMavenCentral() signAllPublications() @@ -39,4 +87,4 @@ mavenPublishing { inceptionYear = "2025" url = "https://github.com/quran/mobile-sync" } -} \ No newline at end of file +}