Guides
Shipping
Builds for the App Store and Google Play, and what ryui.json says of the app.
ryui build makes the builds the stores take, from the app's directory
(or the one given), into its dist.
ryui.json
{
"name": "Notes",
"id": "com.example.notes",
"version": "1.2",
"build": 7,
"icon": "icon.png",
"ios": { "team": "ABCDE12345" },
"android": { "keystore": "upload.jks", "keyAlias": "upload" }
}nameis under the icon;idis the bundle ID and application ID.versionis what users see;buildcounts the builds uploaded, and must grow with each.iconis a square PNG, 1024×1024, without transparency for iOS.orientations:portrait,landscape(both by default), on phones. On iPad the app takes every orientation, as Split View, Slide Over and Stage Manager need; Android 16 likewise ignores it on tablets and foldables.ios.iphoneOnlyleaves iPad out. By default the app is an iPad app too, at whatever size the user gives its window; on Android it's resizable for split screen and desktop windows. Lay out fromui.UseEnvironment()'sWidthandHeight, the window's size, not the device's.accentis the app's accent color,"#rrggbb": the tint on iOS, the seed of the Material 3 color scheme on Android.schemeopens the app formyapp://URLs, andlinksfor https links to your domains (universal links, App Links);ui.OnOpenURLgets them.shareTargetoffers the app in other apps' share sheets, for text and links;ui.OnSharegets them. On iOS it's a share extension in the app, with a bundle ID of its own (the app's plus.share).ios.usagegives the purpose strings of protected resources (NSCameraUsageDescription...), andios.infoPlistadds keys to Info.plist;android.permissionsadds permissions.
The packages an app imports give it the permissions and purpose strings
they need: ryui/camera the camera's, ryui/location the location's.
Write your own in ios.usage: the default is generic.
iOS
ryui build -iosmakes a .ipa signed for the App Store, with the team's distribution
certificate (in your keychain, from Xcode's Accounts) and a provisioning
profile for the app's ID: ios.profile, or -profile, else the best
match installed. -distribution ad-hoc or development signs it for the
devices of its profile instead, and -simulator makes an unsigned .app
for the simulator.
With shareTarget, the share extension is signed too, with a profile
for its own ID (com.example.notes.share for com.example.notes), from
the app's team: ios.shareProfile, else the best match installed. A
wildcard profile (com.example.*) serves both. Register the extension's
ID at developer.apple.com and make it a profile as you did the app's.
Upload it with Apple's Transporter, or:
xcrun altool --upload-app -f dist/Notes.ipa -t ios --apiKey KEY --apiIssuer ISSUERPrivacy manifest. The App Store requires one, giving a reason for
each API of Apple's list the app uses. RyUI writes it, from what the
binary uses: Go's runtime reads the clock and file times, SQLite the disk
space; it declares no tracking and no data collected. If your app
collects data or tracks, write your own as ios/PrivacyInfo.xcprivacy,
with those reasons in it too; RyUI uses it instead.
Android
A release is signed with your upload key. Make one, once, and keep it safe:
keytool -genkeypair -v -keystore upload.jks -alias upload -keyalg RSA -keysize 2048 -validity 10000Name it in ryui.json (android.keystore, android.keyAlias), and give
its password in the environment:
RYUI_KEYSTORE_PASSWORD=... ryui build -android(RYUI_KEY_PASSWORD too if the key's differs; RYUI_KEYSTORE and
RYUI_KEY_ALIAS name them in CI.) It makes an .aab for Google Play and
an .apk to install yourself, for 64-bit and 32-bit ARM phones and
x86_64 (emulators, Chromebooks), Android 8 and later.
What the stores check
Run the release build on a phone before you upload it: ryui build -android's .apk with adb install, and an iOS build with
-distribution development. Then a first upload through TestFlight and a
Play internal test, which check what a simulator can't.
In CI
This GitHub Actions workflow builds both apps when you push a tag like
v1.2, uploads the iOS build to TestFlight and the Android build to
Play's internal test track, and attaches the .ipa and the .apk to a
draft GitHub release for the tag, each as soon as it's built; publish
the draft when you're ready. (Run by hand, it keeps them as the run's
artifacts only.) It expects the app at the root of its repository. Do
your first uploads by hand: Play won't take one through its API until
the app has had one.
# .github/workflows/release.yml
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write # to attach the builds to the tag's release
jobs:
release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- run: gh release create "$GITHUB_REF_NAME" --draft --generate-notes --verify-tag
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ios:
needs: release
if: ${{ !failure() && !cancelled() }} # and when run by hand, which skips release
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version-file: go.mod }
- name: the ryui CLI, at the app's version of ryui
run: go install github.com/egoist/ryui/cmd/ryui@$(go list -m -f '{{.Version}}' github.com/egoist/ryui)
- name: the distribution certificate and the profile
env:
CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
PROFILE: ${{ secrets.IOS_PROFILE }}
run: |
KC=$RUNNER_TEMP/signing.keychain-db
PW=$(openssl rand -hex 16)
security create-keychain -p "$PW" "$KC"
security set-keychain-settings -lut 3600 "$KC"
security unlock-keychain -p "$PW" "$KC"
echo "$CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 -k "$KC" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -k "$PW" "$KC"
security list-keychains -d user -s "$KC" $(security list-keychains -d user | tr -d '"')
echo "$PROFILE" | base64 --decode > $RUNNER_TEMP/app.mobileprovision
# With shareTarget, the extension's profile, installed where ryui looks:
# mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
# echo "$SHARE_PROFILE" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/share.mobileprovision
- run: ryui build -ios -profile $RUNNER_TEMP/app.mobileprovision
- uses: actions/upload-artifact@v4
with: { name: ios, path: dist/*.ipa }
- name: attach to the tag's release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release upload "$GITHUB_REF_NAME" dist/*.ipa --clobber
- name: upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY: ${{ secrets.ASC_KEY }}
run: |
mkdir -p ~/.appstoreconnect/private_keys
echo "$ASC_KEY" > ~/.appstoreconnect/private_keys/AuthKey_$ASC_KEY_ID.p8
xcrun altool --upload-app -f dist/*.ipa -t ios --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
android:
needs: release
if: ${{ !failure() && !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version-file: go.mod }
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 17 }
- uses: android-actions/setup-android@v3
with: { packages: platform-tools }
- name: an NDK
run: sdkmanager "ndk;27.1.12297006"
- name: the ryui CLI, at the app's version of ryui
run: go install github.com/egoist/ryui/cmd/ryui@$(go list -m -f '{{.Version}}' github.com/egoist/ryui)
- name: the upload key
env:
KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
run: echo "$KEYSTORE" | base64 --decode > $RUNNER_TEMP/upload.jks
- run: ryui build -android
env:
RYUI_KEYSTORE: ${{ runner.temp }}/upload.jks
RYUI_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
- uses: actions/upload-artifact@v4
with:
name: android
path: |
dist/*.aab
dist/*.apk
- name: attach to the tag's release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release upload "$GITHUB_REF_NAME" dist/*.apk --clobber
- uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
packageName: com.example.notes
releaseFiles: dist/*.aab
track: internalIts secrets (Settings › Secrets and variables › Actions), the files in
base64 (base64 -i file | pbcopy):
IOS_CERTIFICATE: your Apple Distribution certificate with its private key, a.p12exported from Keychain Access;IOS_CERTIFICATE_PASSWORDthe password you exported it with.IOS_PROFILE: the app's App Store provisioning profile, from developer.apple.com (withshareTarget,SHARE_PROFILEthe share extension's).ASC_KEY_ID,ASC_ISSUER_IDandASC_KEY(the.p8's text): an App Store Connect API key with the App Manager role, from Users and Access › Integrations.ANDROID_KEYSTORE: the upload key's.jks;ANDROID_KEYSTORE_PASSWORDits password (addRYUI_KEY_PASSWORDif the key's differs).PLAY_SERVICE_ACCOUNT: a Google Cloud service account's JSON key, invited in the Play Console's Users and permissions with the right to release to testing tracks.
The CLI carries the iOS and Android hosts, so it's installed at the
version of RyUI in the app's go.mod. build in ryui.json must still
grow with each upload: bump it before you tag. The App Store takes builds
made with a recent Xcode only; if the runner's default is older, pick
one with sudo xcode-select -s /Applications/Xcode_<version>.app.