Skip to content

feat(reader): replace Readium with Skia-based rendering #402

feat(reader): replace Readium with Skia-based rendering

feat(reader): replace Readium with Skia-based rendering #402

Workflow file for this run

name: Build iOS IPA
on:
workflow_dispatch:
pull_request:
branches: ["main"]
paths:
- "apps/mobile/**"
- "packages/**"
- "package.json"
- "package-lock.json"
- "tsconfig.base.json"
- ".github/workflows/build_ios_ipa.yml"
push:
branches: ["main"]
tags: ["v*"]
paths:
- "apps/mobile/**"
- "packages/**"
- "package.json"
- "package-lock.json"
- "tsconfig.base.json"
- ".github/workflows/build_ios_ipa.yml"
permissions:
contents: read
concurrency:
group: build-ios-${{ github.ref }}
cancel-in-progress: true
jobs:
build-ipa:
name: Build Unsigned IPA
runs-on: macos-26
timeout-minutes: 45
env:
CCACHE_COMPILERCHECK: content
CCACHE_BASEDIR: ${{ github.workspace }}
CCACHE_NOHASHDIR: "true"
CCACHE_MAXSIZE: 1G
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Set Build Metadata
run: |
set -euo pipefail
git fetch --no-tags origin main:refs/remotes/origin/main
RELEASE_VERSION=""
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
BUILD_NUMBER="$(git rev-list --count origin/main)"
BUILD_CHANNEL="pr"
BUILD_LABEL="PR #${{ github.event.pull_request.number }}"
ARTIFACT_ID="pr-${{ github.event.pull_request.number }}-${GITHUB_SHA:0:7}"
elif [ "$GITHUB_REF_TYPE" = "tag" ]; then
if [[ ! "$GITHUB_REF_NAME" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::Release tag must use vX.Y.Z format: $GITHUB_REF_NAME"
exit 1
fi
RELEASE_VERSION="${BASH_REMATCH[1]}"
BUILD_NUMBER="$(git rev-list --count HEAD)"
BUILD_CHANNEL="tag"
BUILD_LABEL="Build $BUILD_NUMBER"
ARTIFACT_ID="$GITHUB_REF_NAME"
elif [ "$GITHUB_REF_NAME" = "main" ]; then
BUILD_NUMBER="$(git rev-list --count HEAD)"
BUILD_CHANNEL="main"
BUILD_LABEL="Build $BUILD_NUMBER"
ARTIFACT_ID="main-${GITHUB_SHA:0:7}"
else
BUILD_NUMBER="$(git rev-list --count origin/main)"
BUILD_CHANNEL="branch"
BUILD_LABEL="$GITHUB_REF_NAME"
SAFE_REF="${GITHUB_REF_NAME//\//-}"
ARTIFACT_ID="branch-${SAFE_REF}-${GITHUB_SHA:0:7}"
fi
{
echo "APP_VERSION=$RELEASE_VERSION"
echo "APP_BUILD_NUMBER=$BUILD_NUMBER"
echo "APP_BUILD_CHANNEL=$BUILD_CHANNEL"
echo "APP_BUILD_LABEL=$BUILD_LABEL"
echo "APP_ARTIFACT_ID=$ARTIFACT_ID"
} >> "$GITHUB_ENV"
- name: Verify release tag target
# 与 Android 保持一致:tag 构建不应依赖仓库层 Ruleset 才能拦住坏 tag。
if: github.ref_type == 'tag'
run: |
set -euo pipefail
git fetch --no-tags origin main:refs/remotes/origin/main
git merge-base --is-ancestor "$GITHUB_SHA" origin/main || {
echo "::error::Release tag must point to a commit reachable from main."
exit 1
}
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install JavaScript dependencies
run: npm ci
- name: Select Xcode 26.6
run: |
set -euo pipefail
XCODE_APP="$(
find /Applications -maxdepth 1 -type d \
-name 'Xcode_26.6*.app' |
sort -V |
tail -1
)"
if [ -z "$XCODE_APP" ]; then
echo "::error::Xcode 26.6 is not installed on this runner."
exit 1
fi
# export 使当前步骤也能读到(GITHUB_ENV 只影响后续步骤)。
export DEVELOPER_DIR="$XCODE_APP/Contents/Developer"
echo "DEVELOPER_DIR=$DEVELOPER_DIR" >> "$GITHUB_ENV"
xcodebuild -version
xcodebuild -showsdks | head -5
- name: Install ccache
run: |
if ! command -v ccache >/dev/null 2>&1; then
if brew tap | grep -Fxq 'aws/tap'; then
brew untap aws/tap
fi
brew install ccache
fi
# Xcode 26 omits user-defined build settings from CompileC environments
# (facebook/react-native#55381), so pass the binary as a real process env var.
echo "CCACHE_BINARY=$(command -v ccache)" >> "$GITHUB_ENV"
ccache --version | head -1
- name: Restore iOS compiler cache
id: ios-ccache
uses: actions/cache/restore@v5
with:
path: ~/Library/Caches/ccache
key: >-
ios-ccache-${{ runner.os }}-xcode26.6-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: |
ios-ccache-${{ runner.os }}-xcode26.6-${{ hashFiles('package-lock.json') }}-
ios-ccache-${{ runner.os }}-xcode26.6-
- name: Reset ccache statistics
run: ccache --zero-stats
- name: Show build environment
run: |
echo "BUILD_CHANNEL=${{ env.APP_BUILD_CHANNEL }}"
echo "BUILD_LABEL=${{ env.APP_BUILD_LABEL }}"
echo "BUILD_NUMBER=${{ env.APP_BUILD_NUMBER }}"
echo "RELEASE_VERSION=${{ env.APP_VERSION }}"
- name: Generate iOS project
working-directory: apps/mobile
env:
APP_VERSION: ${{ env.APP_VERSION }}
APP_BUILD_NUMBER: ${{ env.APP_BUILD_NUMBER }}
APP_BUILD_CHANNEL: ${{ env.APP_BUILD_CHANNEL }}
APP_BUILD_LABEL: ${{ env.APP_BUILD_LABEL }}
run: npx expo prebuild --non-interactive --no-install --platform ios
- name: Restore CocoaPods cache
id: ios-pods
uses: actions/cache/restore@v5
with:
path: |
~/Library/Caches/CocoaPods
apps/mobile/ios/Pods
apps/mobile/ios/Podfile.lock
key: >-
ios-pods-${{ runner.os }}-xcode26.6-${{
hashFiles(
'package-lock.json',
'apps/mobile/package.json',
'apps/mobile/app.config.ts',
'apps/mobile/plugins/**',
'apps/mobile/modules/**/ios/**',
'apps/mobile/modules/**/*.podspec',
'apps/mobile/modules/**/expo-module.config.json'
)
}}
- name: Install CocoaPods dependencies
working-directory: apps/mobile
run: npx pod-install ios
- name: Save CocoaPods cache
# pod install 之后才保存(save 立即执行,放前面会存空目录/旧内容)。
# 只由 main 写入:PR 缓存存 merge-ref scope,关闭后无复用价值。
if: success() && github.ref == 'refs/heads/main'
uses: actions/cache/save@v5
with:
path: |
~/Library/Caches/CocoaPods
apps/mobile/ios/Pods
apps/mobile/ios/Podfile.lock
key: ${{ steps.ios-pods.outputs.cache-primary-key }}
- name: Build unsigned iOS app
run: |
xcodebuild \
-workspace apps/mobile/ios/Novella.xcworkspace \
-scheme Novella \
-configuration Release \
-destination 'generic/platform=iOS' \
-derivedDataPath apps/mobile/ios/build \
-showBuildTimingSummary \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
COMPILER_INDEX_STORE_ENABLE=NO \
build
- name: Show ccache statistics
if: always()
run: ccache --show-stats
- name: Write iOS build summary
if: always()
run: |
STATS="$(ccache --print-stats)"
CACHE_MISSES="$(awk '$1 == "cache_miss" { print $2 }' <<< "$STATS")"
CACHE_HITS="$(
awk '$1 == "direct_cache_hit" || $1 == "preprocessed_cache_hit" { total += $2 } END { print total + 0 }' <<< "$STATS"
)"
CACHEABLE_CALLS="$((CACHE_HITS + CACHE_MISSES))"
HIT_RATE="$(
awk -v hits="$CACHE_HITS" -v calls="$CACHEABLE_CALLS" \
'BEGIN { if (calls > 0) printf "%.1f%%", hits * 100 / calls; else printf "—" }'
)"
CACHE_SIZE_KIB="$(awk '$1 == "cache_size_kibibyte" { print $2 }' <<< "$STATS")"
CACHE_SIZE="$(
awk -v kib="$CACHE_SIZE_KIB" \
'BEGIN { if (kib >= 1048576) printf "%.1f GiB", kib / 1048576; else printf "%.1f MiB", kib / 1024 }'
)"
XCODE_VERSION="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
case "${{ job.status }}" in
success) BUILD_OUTCOME="✅" ;;
failure) BUILD_OUTCOME="❌" ;;
cancelled) BUILD_OUTCOME="⏹️" ;;
*) BUILD_OUTCOME="${{ job.status }}" ;;
esac
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
| Xcode Workspace | Scheme | Configuration | Xcode Version | Build Outcome |
| --- | --- | --- | --- | :---: |
| Novella.xcworkspace | Novella | Release | $XCODE_VERSION | $BUILD_OUTCOME |
### Compiler Caching - ⚡ ccache
| Cacheable Calls | Hits | Misses | Hit Rate | Cache Size |
| ---: | ---: | ---: | ---: | ---: |
| $CACHEABLE_CALLS | $CACHE_HITS | $CACHE_MISSES | $HIT_RATE | $CACHE_SIZE |
EOF
- name: Save iOS compiler cache
# 编译完成之后才保存(save 立即执行)。
# 只由 main 写入:PR 缓存存 merge-ref scope,关闭后无复用价值,
# 且避免 1G 条目快速挤爆 10G 仓库缓存上限。
if: success() && github.ref == 'refs/heads/main'
uses: actions/cache/save@v5
with:
path: ~/Library/Caches/ccache
key: ${{ steps.ios-ccache.outputs.cache-primary-key }}
- name: Verify built version matches metadata
run: |
set -euo pipefail
APP_PATH="apps/mobile/ios/build/Build/Products/Release-iphoneos/Novella.app"
test -d "$APP_PATH"
BUILT_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PATH/Info.plist")"
BUILT_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_PATH/Info.plist")"
echo "Built version: $BUILT_VERSION ($BUILT_BUILD)"
if [ -n "${{ env.APP_VERSION }}" ] && [ "$BUILT_VERSION" != "${{ env.APP_VERSION }}" ]; then
echo "::error::Built version $BUILT_VERSION does not match tag version ${{ env.APP_VERSION }}"
exit 1
fi
if [ "$BUILT_BUILD" != "${{ env.APP_BUILD_NUMBER }}" ]; then
echo "::error::Built build number $BUILT_BUILD does not match ${{ env.APP_BUILD_NUMBER }}"
exit 1
fi
- name: Package IPA
run: |
mkdir Payload
cp -R apps/mobile/ios/build/Build/Products/Release-iphoneos/Novella.app Payload/
zip -qry "novella-${{ env.APP_ARTIFACT_ID }}-unsigned.ipa" Payload
- name: Upload IPA
uses: actions/upload-artifact@v7
with:
name: novella-unsigned-ipa-${{ env.APP_ARTIFACT_ID }}
path: novella-*-unsigned.ipa
if-no-files-found: error
retention-days: 14
compression-level: 0