Skip to content

Publish

Publish #152

Workflow file for this run

name: Publish
on:
workflow_dispatch:
inputs:
platform:
description: 'Which platform(s) to publish'
required: true
default: 'all'
type: choice
options:
- all
- android
- ios
- mac
jobs:
publish-android-app:
if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }}
runs-on: ubuntu-latest
permissions:
contents: 'write'
id-token: 'write'
steps:
- name: Checkout Code
uses: actions/checkout@v5
- name: Setup java
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Create Google services file
shell: bash
env:
# Map the secret to a shell variable here
GOOGLE_SERVICES_ENCODED: ${{ secrets.GOOGLE_SERVICES_BASE_64 }}
run: |
# Create the directory structure
mkdir -p "apps/androidApp/src/release/"
# Decode the env variable into the file
echo "$GOOGLE_SERVICES_ENCODED" | openssl base64 -d -A \
-out "apps/androidApp/src/release/google-services.json"
- name: Build Unsigned AAB
env:
HERON_ENDPOINT: ${{ secrets.HERON_ENDPOINT }}
run: |
./gradlew spotlessCheck \
bundleRelease \
-Pheron.versionCode=${{ github.run_number }} \
-Pheron.isRelease=true \
-Pheron.endpoint="$HERON_ENDPOINT"
- name: Sign AAB
uses: r0adkll/sign-android-release@v1
id: sign_app
with:
releaseDirectory: apps/androidApp/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.SIGNING_KEY_BASE_64 }}
alias: ${{ secrets.ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Get play store upload credentials
uses: google-github-actions/auth@v2
id: auth
with:
project_id: 'heron-d0ff3'
workload_identity_provider: projects/313045152492/locations/global/workloadIdentityPools/github/providers/github-actions-provider
service_account: heron-play-store-publish@heron-d0ff3.iam.gserviceaccount.com
- name: Upload to play store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: ${{ steps.auth.outputs.credentials_file_path }}
packageName: com.tunjid.heron
releaseFiles: ${{steps.sign_app.outputs.signedReleaseFile}}
track: internal
mappingFile: apps/androidApp/build/outputs/mapping/release/mapping.txt
debugSymbols: apps/androidApp/build/intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib
- name: Build and sign per-ABI sideload APKs
env:
HERON_ENDPOINT: ${{ secrets.HERON_ENDPOINT }}
SIGNING_KEY_BASE_64: ${{ secrets.SIGNING_KEY_BASE_64 }}
ALIAS: ${{ secrets.ALIAS }}
KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
# Per-ABI split APKs (plus a universal fallback) for GitHub sideloads. litertlm's native
# runtime is packaged per architecture, so the arm64-v8a APK (~37 MB) is far smaller than
# the fused universal (~68 MB). This is a separate build from the Play AAB above; with
# heron.isRelease=true the APKs come out UNSIGNED, so zipalign + apksigner each with the
# same release keystore the AAB uses.
./gradlew assembleRelease \
-Pheron.versionCode=${{ github.run_number }} \
-Pheron.isRelease=true \
-Pheron.endpoint="$HERON_ENDPOINT"
echo "$SIGNING_KEY_BASE_64" | base64 -d > "${RUNNER_TEMP}/release.keystore"
BUILD_TOOLS="$(ls -d "${ANDROID_HOME}"/build-tools/* | sort -V | tail -1)"
OUT=apps/androidApp/build/outputs/apk/release/signed
mkdir -p "$OUT"
for apk in apps/androidApp/build/outputs/apk/release/*-release-unsigned.apk; do
name="$(basename "$apk")"
base="${name%-unsigned.apk}"
"${BUILD_TOOLS}/zipalign" -f -p 4 "$apk" "${RUNNER_TEMP}/${base}-aligned.apk"
"${BUILD_TOOLS}/apksigner" sign \
--ks "${RUNNER_TEMP}/release.keystore" \
--ks-pass "pass:${KEY_STORE_PASSWORD}" \
--ks-key-alias "${ALIAS}" \
--key-pass "pass:${KEY_PASSWORD}" \
--out "${OUT}/${base}.apk" \
"${RUNNER_TEMP}/${base}-aligned.apk"
done
ls -lh "$OUT"
- name: Upload to GitHub releases
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ github.run_number }}
name: Heron v${{ github.run_number }}
files: apps/androidApp/build/outputs/apk/release/signed/*.apk
draft: true
allow_updates: true
publish-ios-app:
if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }}
runs-on: macos-latest
permissions:
contents: 'write'
steps:
- name: Checkout Code
uses: actions/checkout@v5
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Setup java
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Cache KMP tooling
uses: actions/cache@v4
with:
path: |
~/.konan
key: ${{ runner.os }}-v1-${{ hashFiles('gradle/libs.versions.toml') }}
- name: Create Firebase Plist
env:
FIREBASE_IOS_PLIST: ${{ secrets.FIREBASE_IOS_PLIST }}
run: |
echo "$FIREBASE_IOS_PLIST" | base64 -d \
> apps/iosApp/iosApp/GoogleService-Info.plist
- name: Import signing certificate
uses: apple-actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.IOS_CERTIFICATES_P12 }}
p12-password: ${{ secrets.IOS_CERTIFICATES_PASSWORD }}
- name: Download provisioning profiles
uses: apple-actions/download-provisioning-profiles@v2
with:
bundle-id: com.tunjid.heron
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
- name: Set version and build number
run: |
# Axion's currentVersion task prints "Project version: X.Y.Z", not
# just the version — grab the version number with a regex to match
# what Android uses via scmVersion.version, stripping any suffix
# like -SNAPSHOT.
RAW=$(./gradlew -q currentVersion)
VERSION=$(printf '%s\n' "$RAW" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n 1)
if [ -z "$VERSION" ]; then
echo "Failed to parse version from: $RAW" >&2
exit 1
fi
echo "Extracted version: $VERSION"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" \
apps/iosApp/iosApp/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${{ github.run_number }}" \
apps/iosApp/iosApp/Info.plist
- name: Patch project for manual signing
env:
PROFILE_NAME: ${{ secrets.IOS_DIST_PROVISIONING_PROFILE_NAME }}
run: |
PBXPROJ=apps/iosApp/iosApp.xcodeproj/project.pbxproj
# Scope manual signing to the iosApp target only (SPM deps keep their
# automatic/no-profile defaults). Escape the profile name for sed.
ESCAPED_PROFILE=$(printf '%s\n' "$PROFILE_NAME" | sed 's|[&/\]|\\&|g')
sed -i '' \
-e 's|CODE_SIGN_STYLE = Automatic;|CODE_SIGN_STYLE = Manual;|g' \
-e 's|CODE_SIGN_IDENTITY = "Apple Development";|CODE_SIGN_IDENTITY = "Apple Distribution";|g' \
-e "s|PROVISIONING_PROFILE_SPECIFIER = \"\";|PROVISIONING_PROFILE_SPECIFIER = \"$ESCAPED_PROFILE\";|g" \
"$PBXPROJ"
- name: Patch entitlements for production APNs
run: |
# Local dev keeps aps-environment=development so running from Xcode
# hits the sandbox APNs gateway. TestFlight / App Store builds need
# production APNs, so we flip the entitlement at CI time only.
/usr/libexec/PlistBuddy -c "Set :aps-environment production" \
apps/iosApp/iosApp/iosApp.entitlements
- name: Build archive
env:
HERON_ENDPOINT: ${{ secrets.HERON_ENDPOINT }}
ORG_GRADLE_PROJECT_heron.isRelease: "true"
ORG_GRADLE_PROJECT_heron.versionCode: ${{ github.run_number }}
run: |
cd apps/iosApp
xcrun xcodebuild \
-scheme "iosApp" \
-configuration "Release" \
-sdk "iphoneos" \
-parallelizeTargets \
-showBuildTimingSummary \
-derivedDataPath "${RUNNER_TEMP}/Build/DerivedData" \
-archivePath "${RUNNER_TEMP}/Build/Archives/heron.xcarchive" \
-resultBundlePath "${RUNNER_TEMP}/Build/Artifacts/heron.xcresult" \
-destination "generic/platform=iOS" \
archive
- name: Generate ExportOptions.plist
run: |
cat <<EOF > ${RUNNER_TEMP}/Build/ExportOptions.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>export</string>
<key>method</key>
<string>app-store</string>
<key>signingStyle</key>
<string>manual</string>
<key>generateAppStoreInformation</key>
<true/>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>${{ secrets.APPSTORE_TEAM_ID }}</string>
<key>uploadSymbols</key>
<true/>
<key>provisioningProfiles</key>
<dict>
<key>com.tunjid.heron</key>
<string>${{ secrets.IOS_DIST_PROVISIONING_PROFILE_NAME }}</string>
</dict>
</dict>
</plist>
EOF
- name: Export IPA
run: |
xcrun xcodebuild \
-exportArchive \
-exportOptionsPlist "${RUNNER_TEMP}/Build/ExportOptions.plist" \
-archivePath "${RUNNER_TEMP}/Build/Archives/heron.xcarchive" \
-exportPath "${RUNNER_TEMP}/Build/Archives/heron.xcarchive" \
PRODUCT_BUNDLE_IDENTIFIER="com.tunjid.heron"
echo "IPA_PATH=${RUNNER_TEMP}/Build/Archives/heron.xcarchive/heron.ipa" >> $GITHUB_ENV
- name: Upload to TestFlight
uses: Apple-Actions/upload-testflight-build@v1
with:
app-path: ${{ env.IPA_PATH }}
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
publish-mac-app:
if: ${{ inputs.platform == 'all' || inputs.platform == 'mac' }}
runs-on: macos-latest
permissions:
contents: 'write'
steps:
- name: Checkout Code
uses: actions/checkout@v5
- name: Setup java
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Cache KMP tooling
uses: actions/cache@v4
with:
path: |
~/.konan
key: ${{ runner.os }}-v1-${{ hashFiles('gradle/libs.versions.toml') }}
- name: Import signing certificate
uses: apple-actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.MACOS_SIGNING_CERTIFICATE_P12_DATA }}
p12-password: ${{ secrets.MACOS_SIGNING_CERTIFICATE_PASSWORD }}
- name: Build and sign release DMG
env:
HERON_ENDPOINT: ${{ secrets.HERON_ENDPOINT }}
run: |
./gradlew :desktopApp:packageReleaseDmg \
-Pheron.versionCode=${{ github.run_number }} \
-Pheron.endpoint="$HERON_ENDPOINT" \
-Pheron.macOS.signing.identity="${{ secrets.MACOS_SIGNING_IDENTITY }}"
- name: Notarize and staple DMG
env:
MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.MACOS_NOTARIZATION_APPLE_ID }}
MACOS_NOTARIZATION_PASSWORD: ${{ secrets.MACOS_NOTARIZATION_PASSWORD }}
MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.MACOS_NOTARIZATION_TEAM_ID }}
run: |
set -euo pipefail
DMG_PATH=$(find apps/desktopApp/build/release/main-release/dmg -name "*.dmg" | head -1)
if [ -z "$DMG_PATH" ]; then echo "::error::No DMG found to notarize"; exit 1; fi
# `notarytool submit --wait` exits 0 even when the result is Invalid, so gate on the
# status explicitly and dump the notarization log (the per-file reasons) on failure.
SUBMIT_JSON=$(xcrun notarytool submit "$DMG_PATH" \
--apple-id "$MACOS_NOTARIZATION_APPLE_ID" \
--password "$MACOS_NOTARIZATION_PASSWORD" \
--team-id "$MACOS_NOTARIZATION_TEAM_ID" \
--output-format json --wait)
echo "$SUBMIT_JSON"
SUBMISSION_ID=$(echo "$SUBMIT_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
STATUS=$(echo "$SUBMIT_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin)["status"])')
if [ "$STATUS" != "Accepted" ]; then
echo "::error::Notarization returned status '$STATUS'. Fetching log for $SUBMISSION_ID:"
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$MACOS_NOTARIZATION_APPLE_ID" \
--password "$MACOS_NOTARIZATION_PASSWORD" \
--team-id "$MACOS_NOTARIZATION_TEAM_ID" || true
exit 1
fi
xcrun stapler staple "$DMG_PATH"
- name: Upload to GitHub releases
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ github.run_number }}
name: Heron v${{ github.run_number }}
files: apps/desktopApp/build/release/main-release/dmg/*.dmg
draft: true
allow_updates: true