Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 149 additions & 21 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,30 +1,158 @@
name: Gradle CI
name: Build

on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]

concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true # Cancel previous builds

jobs:
build:
test:
runs-on: macos-latest

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
- uses: actions/checkout@v4

- name: Clean project
run: ./gradlew clean

- name: Run tests
run: ./gradlew allTests
- 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

build-dev:
needs: test
runs-on: macos-latest
if: github.ref == 'refs/heads/main'
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 QuranSyncUmbrella-${{ steps.version.outputs.version }}.xcframework.zip QuranSyncUmbrella.xcframework

- name: Calculate Checksum
id: checksum
run: |
CHECKSUM=$(swift package compute-checksum umbrella/build/XCFrameworks/release/QuranSyncUmbrella-${{ 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/QuranSyncUmbrella-${{ 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: "QuranSyncUmbrella",
url: "https://github.com/quran/mobile-sync/releases/download/${{ steps.version.outputs.version }}/QuranSyncUmbrella-${{ 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 5 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.ref == 'refs/heads/main'
steps:
- 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 5 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(5); // Skip first 5 (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,
});
}
177 changes: 177 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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
# XCFramework is already named correctly: QuranSyncUmbrella.xcframework
zip -r QuranSyncUmbrella.xcframework.zip QuranSyncUmbrella.xcframework

- name: Calculate Checksum
id: checksum
run: |
CHECKSUM=$(swift package compute-checksum umbrella/build/XCFrameworks/release/QuranSyncUmbrella.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/QuranSyncUmbrella.xcframework.zip
body_path: release_notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Summary
run: |
echo "✅ Released QuranSyncUmbrella v${{ steps.next_version.outputs.version }}"
echo "🏷️ Tag: v${{ steps.next_version.outputs.version }}"
echo "📦 Asset: QuranSyncUmbrella.xcframework.zip"
echo "🔗 Release: https://github.com/quran/mobile-sync/releases/tag/v${{ steps.next_version.outputs.version }}"
30 changes: 30 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// swift-tools-version:5.5
import PackageDescription

// Set to true for local development
let useLocalBuild = false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't tried this myself but we probably could overwrite this with an env var and have it default to false. See https://gist.github.com/Sorix/21e61347f478ae2e83ef4d8a92d933af

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's clear from mobile-sync end.

From quran-ios's side, we will need to point to the local path as well. I've experimented with this and found that the best way to control both Package.swift files is through setting the evn var from Terminal before opening Xcode. Otherwise, setting it through the scheme's variables won't reflect in package resolution.

It will look something like this:

let useLocalQuranSync = ProcessInfo.processInfo.environment["QURAN_SYNC_LOCAL_BUILD"] == "true"
// ...
// ...
useLocalQuranSync ? .package(name: "QuranSync-lib", path: "../mobile-sync/") : .package(name: "QuranSync-lib", url: "https://github.com/quran/mobile-sync.git", from: "1.0.0"),

If that feels too awkward, the other solution I'd think of to setenv from quran-ios's Package.swift. This will be inputted to quran-ios's package by reading it from a side file or editting it manually.

@mohamede1945 Let me know what you think.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me. Thank you!


let package = Package(
name: "QuranSyncUmbrella",
platforms: [
.iOS(.v13)
],
products: [
.library(
name: "QuranSyncUmbrella",
targets: ["QuranSyncUmbrella"]
)
],
targets: useLocalBuild ? [
.binaryTarget(
name: "QuranSyncUmbrella",
path: "umbrella/build/XCFrameworks/release/QuranSyncUmbrella.xcframework"
)
] : [
.binaryTarget(
name: "QuranSyncUmbrella",
url: "https://github.com/quran/mobile-sync/releases/download/{VERSION}/QuranSyncUmbrella.xcframework.zip",
checksum: "{CHECKSUM_TO_BE_REPLACED_BY_CI}"
)
]
)
Comment on lines +24 to +30
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per our discussion and https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-spm-export.html#set-up-remote-integration, we need a separate repo to host the package.swift.

Store the Package.swift file and the code that should be packaged into an XCFramework in separate Git repositories. This allows versioning the Swift manifest separately from the project the file describes. This is the recommended approach: it allows scaling and is generally easier to maintain.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fail to see why we need to separate the Swift's package definition and generated XCFramework hosting to a separate repo.

JetBrains mentions the approach used here as the second approach, and the drawback mentioned is:

... but keep in mind that, in this case, the Swift package and the code will use the same versioning. SPM uses Git tags for versioning packages, which can conflict with tags used for your project.

This is actually one of the reasons I resorted to keeping both in the same repo. Whenever the library's components the exported library will have to be updated, so the Swift package will follow the KMP packages versions.

We may have some problems if we ever reach the point that the Kotlin packages will need to have different versions.

Let me know what you think.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is running into the exact issue described in the approach: "SPM uses Git tags for versioning packages, which can conflict with tags used for your project."

To work around it, the code introduces a non-standard workflow: creating a release and then immediately committing another change to bump Package.swift for every single change in the repo.

This approach is problematic because it deviates from standard git practices, clutters the commit history, and distorts the meaning of releases.

My suggestion is to go with the recommended approach for creating a separate repo for the package.swift, please.

Copy link
Collaborator Author

@mohannad-hassan mohannad-hassan Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I understood from that is when the Swift package's versioning would collide with the KMP project's versioning, which we don't have. I see that would happen if the main repo needed to handle other concerns that those exposed for iOS, e.g. if it did contain the final app code, etc...

As for the separate version commit, I believe we will have to do them whenever we handle versioning for the modules here, as well. It would be common to set the version in the gradle files, and edit change logs, so it'd be x.y.z version bump for both gradle and Package.swift, and hence any other needed artifacts.

We could see this behavior in some of the Kotlin dependencies we're using: ktor-3.2.1, ktor-3.3.0, kermit-2.0.8 and sqldelight-2.1.0.

These don't provide a Swift package for a native KMP package, but it shows they're following the same_-ish_ versioning technique we might be following, and since we're not developing anything else here, then it follows that versioning for the Swift package and framework can be de done here and track the same versions.

Copy link
Collaborator Author

@mohannad-hassan mohannad-hassan Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main drawback I see will be if we had to drive versioning of the data repositories and the sync agent separately. If that's the case we will need to get the umbrella Kotlin package out with the Swift package as well.

Edit: even if we have to provide different versions for different libraries, we could go with a prefixed tag strategy. Maybe then I'd see the need for a separate repo for the Swift package as it'd have a new version each time any of the individual modules gets pumped to a new version.

But I don't expect we'd reach that stage, at least soon.

Copy link
Collaborator Author

@mohannad-hassan mohannad-hassan Sep 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

package.swift will always be updated to point to the last created git tag after the fact and hence package.swift will not really be part of the tag's content.

Why would that be the case? Creating a new release will build the needed artifacts, creates the Github release, pumps the version values and commits. We don't currently put the version in Gradle files, but it'd be the same logic.

I depended on Claude to generate the workflows and script files, but I looked it over multiple times and it seems alright. Am I missing something here?


The production of the xcframework should be enough to verify the library builds

I meant the value of continuous integration. If we have the responsibility of building the framework in the same repo, then we can have that check executed on each PR alongside tests.

I don't expect this to be a frequent problem, but it's just an example of what we lose when we separate Package.swift and XCFramework generation in a separate repo.


In both cases, we need to update the package.swift to work locally either with env vars or updating hardcoded values. But the package.swift will need to change

As things are set up now, quran-sync/Package.swift should not be changed when building locally. The env-var set from the host project (i.e. quran-ios) will control if it points to the local package or the remote XCFramework, so the change will be made from the importing project.

The point is, as I see things now, importing the project should be a bit simpler.


Still the main point is what I said about my interpretation of jetbrains' recommendation. It's recommended within the context of exposing few modules from an existing bigger project to an iOS framework. This is my opinion, so I can't find references for that. My clues for that: 1. Jetbrains did mention my approach here as the second recommendation, 2. and the drawback they mentioned does not exist in our case, as I see it.

Moreover, if our usage or the internal structure change, we can easily migrate inshaa Allah.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Salam alikom, Mohannad, let's look at the steps. Let's assume we will not create a new release on each commit because this will leave the repo with just automated releases and commits and make it extremely hard to work with. Let's assume we can manually trigger the release.yml workflow whenever we need to release. The steps are:

  1. Build, test and generate xcframework
  2. Update package.swift
  3. Commit
  4. Create a tag
  5. Push the commit
  6. Push the tag
  7. Create a new github release and upload the xcframework

The problem here is that, package.swift was created and pushed to the repo before xcframework was uploaded. Now, that means we could potentially be in a situation where package.swift point to a non-existent xcframework which is problematic. This is the main major issue, with multiple repos we upload xcframework first and then update package.swift.

There are other issues like the automated commits and automated releases in the mobile-sync repo which is supposed to be not just about releasing new package.swift.

Copy link
Collaborator Author

@mohannad-hassan mohannad-hassan Sep 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not what happens now. The workflow is either triggered from Github, or triggered from scripts/release which does the same thing, so we won't be doing the steps manually, if that's the point.

  • The Github release is made
  • The framework is built and added to the release
  • Package.swift is updated and committed
  • The tag is set for that commit.

I've tested that out on a fork. See the commit and the release. If we needed to update other artifacts with version numbers, we should be able to do that in the same workflow and add it to the same commit.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't create a github release before creating a tag.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, the problem here is not that the approach works or not. The problem here is the approach is brittle and we can easily be in an invalid state.

At this point, I feel we have gone in circles long enough and I don't think is more to say here. Let's revisit this in the team meeting tomorrow.

Loading