Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 149 additions & 42 deletions .github/workflows/release-xcframework.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
name: Release XCFramework

on:
push:
branches:
- main
workflow_dispatch:
inputs:
version:
description: "Version tag (e.g. v3.0.2-guion.19). Leave empty to auto-bump."
description: "Version tag (e.g. v3.0.2-guion.19)."
required: false
type: string

Expand All @@ -17,7 +20,7 @@ concurrency:

jobs:
release:
runs-on: macos-15
runs-on: macos-14

steps:
- name: Generate release bot token
Expand All @@ -30,7 +33,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
token: ${{ steps.app-token.outputs.token }}
ref: main
ref: ${{ github.ref }}
fetch-depth: 0
fetch-tags: true

Expand All @@ -39,15 +42,25 @@ jobs:
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
if [ -n "$INPUT_VERSION" ]; then
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
LATEST=$(git tag --list 'v*-guion.*' --sort=-v:refname | grep -Ev '(dynamic|snapshot)' | head -1)
if [ -z "$LATEST" ]; then
echo "ERROR: no formal v*-guion.* tag found for snapshot base" >&2
exit 1
fi
DATE="$(date -u +%Y%m%d%H%M%S)"
SHORT_SHA="${GITHUB_SHA::7}"
echo "tag=${LATEST}-snapshot.${DATE}.${SHORT_SHA}" >> "$GITHUB_OUTPUT"
elif [ -n "$INPUT_VERSION" ]; then
if [[ ! "$INPUT_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then
echo "ERROR: version must match vMAJOR.MINOR.PATCH[-suffix] (e.g. v3.0.2-guion.19)" >&2
exit 1
fi
echo "tag=${INPUT_VERSION}" >> "$GITHUB_OUTPUT"
else
# Auto-bump: find latest v*-guion.* tag and increment
LATEST=$(git tag --list 'v*-guion.*' --sort=-v:refname | head -1)
LATEST=$(git tag --list 'v*-guion.*' --sort=-v:refname | grep -Ev '(dynamic|snapshot)' | head -1)
if [ -z "$LATEST" ]; then
echo "ERROR: no existing v*-guion.* tags found — provide version manually" >&2
exit 1
Expand All @@ -63,6 +76,18 @@ jobs:
echo "Auto-bumped: ${LATEST} → ${PREFIX}.${NEXT}"
fi

- name: Determine linkage
id: linkage
env:
VERSION_TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
if [[ "$VERSION_TAG" == *snapshot* || "$VERSION_TAG" == *dynamic* ]]; then
echo "linkage=dynamic" >> "$GITHUB_OUTPUT"
else
echo "linkage=static" >> "$GITHUB_OUTPUT"
fi

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 (stable)
with:
Expand All @@ -71,10 +96,12 @@ jobs:
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.0'
xcode-version: '16.2'

- name: Show Xcode SDKs
- name: Show tool versions
run: |
rustc --version
cargo --version
xcodebuild -version
xcodebuild -showsdks

Expand All @@ -91,52 +118,104 @@ jobs:
- name: Cache cargo build artifacts
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: target
key: ${{ runner.os }}-cargo-target-${{ hashFiles('**/Cargo.lock', 'scripts/build_xcframework.sh', '.github/workflows/release-xcframework.yml') }}
path: |
target
ffi/target
key: ${{ runner.os }}-cargo-swift-target-${{ hashFiles('**/Cargo.lock', 'ffi/Cargo.toml', 'ffi/uniffi.toml', 'scripts/package_cargo_swift.sh', '.github/workflows/release-xcframework.yml') }}
restore-keys: |
${{ runner.os }}-cargo-swift-target-

- name: Build XCFramework and generate Swift bindings
timeout-minutes: 30
run: ./scripts/build_xcframework.sh
- name: Install cargo-swift
run: |
set -euo pipefail
cargo install cargo-swift@0.11.1 --locked
cargo swift --version

- name: Package framework with cargo-swift
timeout-minutes: 40
env:
TASKCHAMPION_FFI_LINKAGE: ${{ steps.linkage.outputs.linkage }}
run: |
set -euo pipefail
./scripts/package_cargo_swift.sh "${RUNNER_TEMP}/cargo-swift-output"
rm -rf "$GITHUB_WORKSPACE/ffi/TaskChampionFFI"
cp -R "${RUNNER_TEMP}/cargo-swift-output/TaskChampionFFI" "$GITHUB_WORKSPACE/ffi/TaskChampionFFI"
cp "${RUNNER_TEMP}/cargo-swift-output/TaskChampionFFI/Sources/TaskChampionFFI/taskchampion_ffi.swift" \
"$GITHUB_WORKSPACE/Sources/TaskChampionFFI/TaskChampionFFI.swift"

- name: Validate iOS archive has no bundled SQLite
- name: Inspect cargo-swift output
working-directory: ffi
env:
LINKAGE: ${{ steps.linkage.outputs.linkage }}
run: |
set -euo pipefail
ios_lib="target/aarch64-apple-ios/release/libtaskchampion_ffi.a"
package_dir="TaskChampionFFI"
xcframework="${package_dir}/TaskChampionCore.xcframework"
expected="@rpath/TaskChampionCore.framework/TaskChampionCore"

if ar -t "$ios_lib" | grep -q 'sqlite3\.o$'; then
echo "ERROR: bundled sqlite3.o found in $ios_lib" >&2
if [ ! -d "$xcframework" ]; then
echo "ERROR: cargo-swift did not create ${xcframework}" >&2
find "$package_dir" -maxdepth 4 -print | sort >&2
exit 1
fi

echo "No bundled sqlite3.o found in $ios_lib"
legacy_module="TaskChampionFFI""FFI"
if grep -R -n "$legacy_module" "$package_dir"; then
echo "ERROR: generated package still references the legacy doubled-FFI module" >&2
exit 1
fi

- name: Validate Swift bindings were generated
run: |
[ -s Sources/TaskChampionFFI/TaskChampionFFI.swift ] || {
echo "ERROR: Swift bindings file is missing or empty" >&2; exit 1
}
{
echo "## Package.swift"
sed -n '1,220p' "$package_dir/Package.swift"
echo
echo "## Output tree"
find "$package_dir" -maxdepth 6 -print | sort
echo
echo "## Binary files"
find "$xcframework" -type f -perm -111 -print | sort
echo
echo "## Framework bundles"
find "$xcframework" -name '*.framework' -type d -print | sort
} | tee cargo-swift-report.txt

while IFS= read -r binary; do
echo "## file ${binary}" | tee -a cargo-swift-report.txt
file "$binary" | tee -a cargo-swift-report.txt
if [ "$LINKAGE" = "dynamic" ]; then
install_name="$(otool -D "$binary" | tail -n 1)"
echo "$install_name" | tee -a cargo-swift-report.txt
if [ "$install_name" != "$expected" ]; then
echo "ERROR: unexpected install name for $binary: $install_name" >&2
exit 1
fi
fi
done < <(find "$xcframework" -type f -perm -111 -print | sort)

- name: Zip XCFramework
run: |
zip -r TaskChampionFFIFFI.xcframework.zip TaskChampionFFIFFI.xcframework
[ -s TaskChampionFFIFFI.xcframework.zip ] || { echo "ERROR: zip archive is empty or missing"; exit 1; }
set -euo pipefail
rm -rf TaskChampionCore.xcframework TaskChampionCore.xcframework.zip
cp -R ffi/TaskChampionFFI/TaskChampionCore.xcframework TaskChampionCore.xcframework
zip -r TaskChampionCore.xcframework.zip TaskChampionCore.xcframework
[ -s TaskChampionCore.xcframework.zip ] || { echo "ERROR: zip archive is empty or missing"; exit 1; }

- name: Compute SHA-256 checksum
- name: Compute SwiftPM checksum
id: checksum
run: |
shasum -a 256 TaskChampionFFIFFI.xcframework.zip > checksum.txt
CHECKSUM=$(awk '{print $1}' checksum.txt)
CHECKSUM=$(swift package compute-checksum TaskChampionCore.xcframework.zip)
if [ ${#CHECKSUM} -ne 64 ]; then
echo "ERROR: unexpected checksum length (${#CHECKSUM}): '${CHECKSUM}'" >&2
exit 1
fi
echo "checksum=${CHECKSUM}" >> "$GITHUB_OUTPUT"

# Update Package.swift and commit BEFORE tagging so the tag
# includes the correct URL + checksum. No more chicken-and-egg.
# Update Package.swift and commit BEFORE tagging so the tag includes
# the correct URL + checksum.
- name: Update Package.swift with release URL and checksum
env:
RELEASE_URL: https://github.com/GuionAI/taskchampion/releases/download/${{ steps.version.outputs.tag }}/TaskChampionFFIFFI.xcframework.zip
VERSION_TAG: ${{ steps.version.outputs.tag }}
RELEASE_URL: https://github.com/GuionAI/taskchampion/releases/download/${{ steps.version.outputs.tag }}/TaskChampionCore.xcframework.zip
RELEASE_CHECKSUM: ${{ steps.checksum.outputs.checksum }}
run: |
python3 -c "
Expand Down Expand Up @@ -169,28 +248,56 @@ jobs:
f.write(content)
"

- name: Commit Package.swift and Swift bindings and push
- name: Commit Package.swift and Swift bindings
env:
VERSION_TAG: ${{ steps.version.outputs.tag }}
run: |
git config user.name "guion-release-bot[bot]"
git config user.email "guion-release-bot[bot]@users.noreply.github.com"
git add Package.swift Sources/TaskChampionFFI/TaskChampionFFI.swift
git diff --cached --quiet && { echo "No changes — skipping commit"; } || {
if git diff --cached --quiet; then
echo "No changes — skipping commit"
else
git commit -m "chore(spm): update Package.swift and Swift bindings for ${VERSION_TAG} release"
}
git push origin main
fi
if [[ "$VERSION_TAG" == *snapshot* ]]; then
echo "Snapshot release: keeping the Package.swift/checksum commit tag-only"
else
git push origin "HEAD:${GITHUB_REF_NAME}"
fi

# gh release create owns the tag via --target main, making the tag
# atomic with the release. If this step fails, no tag exists and the
# workflow is safely re-runnable.
# Move the tag after the release commit so SwiftPM sees the matching
# Package.swift URL and checksum when pinning this version.
- name: Create GitHub Release and upload XCFramework zip
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION_TAG: ${{ steps.version.outputs.tag }}
run: |
gh release create "$VERSION_TAG" \
TaskChampionFFIFFI.xcframework.zip \
--target main \
--generate-notes \
--title "$VERSION_TAG"
git tag -f "$VERSION_TAG" HEAD
git push origin "refs/tags/${VERSION_TAG}" --force

if gh release view "$VERSION_TAG" >/dev/null 2>&1; then
gh release upload "$VERSION_TAG" \
TaskChampionCore.xcframework.zip \
--clobber
if [[ "$VERSION_TAG" == *snapshot* ]]; then
gh release edit "$VERSION_TAG" \
--title "$VERSION_TAG" \
--prerelease
else
gh release edit "$VERSION_TAG" \
--title "$VERSION_TAG"
fi
else
release_args=(
"$VERSION_TAG"
TaskChampionCore.xcframework.zip
--target "$(git rev-parse HEAD)"
--generate-notes
--title "$VERSION_TAG"
)
if [[ "$VERSION_TAG" == *snapshot* ]]; then
release_args+=(--prerelease)
fi
gh release create "${release_args[@]}"
fi
Loading