This project uses GitHub Actions for CI and Fastlane for mobile build/upload orchestration. All three platforms (Android, iOS, Desktop) have automated release workflows triggered by git tags.
The recommended path is the automated Bump Version workflow (see Version Management) — it bumps versions, opens a PR, and tags the release on merge. To release manually, push a tag directly:
git tag v1.0.0
git push --tagsEither way, the tag triggers three independent workflows that run in parallel:
| Platform | Workflow | Destination |
|---|---|---|
| Android | android-release.yml |
Play Store (internal track) |
| iOS | ios-release.yml |
App Store Connect (TestFlight) |
| Desktop | desktop-release.yml |
macOS → App Store Connect (Mac App Store); Linux .deb + latest.json feed on GitHub Releases; Windows → Microsoft Store (MSIX) |
All workflows also support manual triggering via the "Run workflow" button in the GitHub Actions UI (workflow_dispatch).
Desktop distribution + updates depend on platform:
| Platform | Distribution | Updates |
|---|---|---|
| macOS | Mac App Store (.pkg uploaded to App Store Connect) |
The Store — the in-app updater is a no-op on macOS (Store policy forbids self-updating, and the sandbox can't replace its own install) |
| Linux | .deb on GitHub Releases |
In-app updater (DesktopUpdater) polls the latest.json feed and prompts the user to download + install |
| Windows | Microsoft Store (MSIX) | The Store — the in-app updater is a no-op on Windows for the same reasons |
Only Linux runs the in-app updater. See macOS: Mac App Store and Windows: Microsoft Store (MSIX) for the store paths.
Fastlane is managed via Bundler. Install it locally with:
bundle installThe Gemfile at the project root declares the Fastlane dependency.
The following repository secrets must be configured in Settings > Secrets and variables > Actions:
| Secret | Description |
|---|---|
SUPABASE_URL |
Supabase project URL |
SUPABASE_KEY |
Supabase anon/public key |
BUGSNAG_API_KEY |
Bugsnag API key for crash reporting |
These are read by BuildKonfig at build time. See buildconfig-setup.md for details.
| Secret | Description |
|---|---|
ANDROID_KEYSTORE_BASE64 |
Release keystore file, base64-encoded (see Generating the keystore) |
ANDROID_KEYSTORE_PASSWORD |
Password for the keystore |
ANDROID_KEY_ALIAS |
Alias of the signing key within the keystore |
ANDROID_KEY_PASSWORD |
Password for the signing key |
| Secret | Description |
|---|---|
PLAY_STORE_SERVICE_ACCOUNT_JSON |
Google Play service account JSON key content (see Play Store setup) |
| Secret | Description |
|---|---|
MATCH_PASSWORD |
Encryption password for the match certificates repository |
MATCH_GIT_BASIC_AUTHORIZATION |
Base64-encoded username:personal_access_token for read access to the certificates repo. See Fastlane Match (iOS Certificates) for the creation/rotation walkthrough. |
| Secret | Description |
|---|---|
ASC_KEY_ID |
App Store Connect API Key ID |
ASC_ISSUER_ID |
App Store Connect API Issuer ID |
ASC_API_KEY |
App Store Connect API private key (.p8 content, base64-encoded) |
APPLE_TEAM_ID |
Apple Developer Team ID |
macOS desktop signing reuses the existing iOS MATCH_* and ASC_* secrets — no desktop-specific secrets are required. The macos job in desktop-release.yml runs fastlane mac release, which fetches the App Store certs + provisioning profile via Fastlane match, resolves the "Apple Distribution" identity into MACOS_SIGN_IDENTITY, builds a Store-signed .pkg with packageReleasePkg, and uploads it to App Store Connect. See macOS: Mac App Store.
Windows code signing is not required — the Microsoft Store signs MSIX packages on ingestion.
Required only for automated MSIX submission. Created from an Azure AD application associated with your Partner Center account.
| Secret | Description |
|---|---|
PARTNER_CENTER_TENANT_ID |
Azure AD tenant ID |
PARTNER_CENTER_CLIENT_ID |
Azure AD application (client) ID |
PARTNER_CENTER_CLIENT_SECRET |
Client secret for that Azure AD application |
PARTNER_CENTER_SELLER_ID |
Seller ID from Partner Center → Account settings |
STORE_PRODUCT_ID |
The Store product/app ID for the reserved app — gates the publish-store job |
STORE_IDENTITY_NAME |
Partner Center Package/Identity Name — substituted into AppxManifest.xml (falls back to a placeholder if unset, so the MSIX still builds) |
STORE_PUBLISHER |
Partner Center Publisher CN=... string — substituted into AppxManifest.xml |
Workflow: .github/workflows/android-release.yml
What it does:
- Decodes the release keystore from the
ANDROID_KEYSTORE_BASE64secret - Builds a signed AAB via
./gradlew :client:composeApp:bundleRelease - Uploads the AAB to the Play Store internal track via Fastlane
supply
Fastlane lane: bundle exec fastlane android release
Signing configuration: The signingConfigs block in client/composeApp/build.gradle.kts reads credentials from environment variables first (used in CI), then falls back to a keystore.properties file at the project root (for local builds):
| Environment Variable | keystore.properties Key | Description |
|---|---|---|
ANDROID_KEYSTORE_FILE |
releaseKeyStore |
Path to the keystore file (defaults to release.keystore) |
ANDROID_KEYSTORE_PASSWORD |
releaseStorePassword |
Password for the keystore |
ANDROID_KEY_ALIAS |
releaseKeyAlias |
Alias of the signing key |
ANDROID_KEY_PASSWORD |
releaseKeyPassword |
Password for the signing key |
For local builds, create a keystore.properties file in the project root (this file is gitignored):
releaseKeyAlias=upload
releaseKeyPassword=YOUR_KEY_PASSWORD
releaseKeyStore=/absolute/path/to/your/keystore
releaseStorePassword=YOUR_STORE_PASSWORDNote: releaseKeyStore must be an absolute path. Relative paths are resolved from the client/composeApp/ module directory, not the project root.
Promoting releases: Builds are uploaded to the internal track. Promote to higher tracks (alpha, beta, production) through the Google Play Console.
Workflow: .github/workflows/ios-release.yml
What it does:
- Fetches signing certificates and provisioning profiles via Fastlane
match(readonly mode) - Builds the IPA via
build_apptargetingiosApp/iosApp.xcodeproj - Uploads to App Store Connect via Fastlane
deliver
Fastlane lane: bundle exec fastlane ios release
Code signing: Uses Fastlane match with a private git repository to store encrypted certificates and provisioning profiles. The repository URL is configured in fastlane/Matchfile.
App Store Connect API: Uses an API key (recommended over Apple ID for CI). The key avoids 2FA prompts and is more reliable for automation.
Workflow: .github/workflows/desktop-release.yml
What it does:
-
The
buildjob builds native packages in parallel on two runners:windows-latest— builds.msivia./gradlew :client:composeApp:packageReleaseMsiubuntu-latest— builds.debvia./gradlew :client:composeApp:packageReleaseDeb
These
packageRelease*tasks (not the plainpackage*variants) are required so the requested task name contains "Release", which setsBuildConfig.IS_DEBUG = falseand hides the developer-only UI. R8/ProGuard minification is disabled for the release build type inclient/composeApp/build.gradle.kts, so the binary is functionally identical to the old debug packaging — it just carries the release marker. -
The
macosjob (tag pushes only) runsfastlane mac release, which builds a Store-signed.pkgvia./gradlew :client:composeApp:packageReleasePkgand uploads it to App Store Connect (see macOS: Mac App Store). -
On Windows, also builds the app image (
createReleaseDistributable) and packs an MSIX viamakeappx -
Uploads the Windows/Linux packages (and the MSIX) as build artifacts. macOS emits no GitHub-Release artifact — its
.pkggoes straight to App Store Connect. -
Generates
latest.json(the in-app update feed) with the Linux download URL only — macOS and Windows are omitted because both ship via their app stores and the updater is a no-op there. -
Creates a GitHub Release from the tag with the Windows/Linux packages +
latest.jsonattached -
Auto-generates release notes from commits since the last tag
-
On tag pushes, the
publish-storejob submits the MSIX to the Microsoft Store via the MSStore CLI (no-ops untilSTORE_PRODUCT_IDis set)
Package configuration: Native distribution settings (package name, version, vendor, platform-specific options) are in client/composeApp/build.gradle.kts under the compose.desktop.application.nativeDistributions block. The macOS signing { } block and the provisioning-profile paths are gated on env vars (MACOS_SIGN_IDENTITY, MACOS_PROVISIONING_PROFILE) so a local packageReleasePkg works unsigned without certs.
The desktop app embeds DesktopUpdater (client/composeApp/src/jvmMain/.../update/). On Linux it polls the stable feed URL on launch:
https://github.com/Plus-Mobile-Apps/chef-mate/releases/latest/download/latest.json
GitHub always serves releases/latest/download/<asset> from the newest non-prerelease release, so no separate hosting is needed. The updater compares the feed version against BuildConfig.VERSION_NAME; if newer, it surfaces a banner to download the .deb and open it. It is a no-op on macOS and Windows by design — both ship through app stores that forbid self-updating and sandbox the install.
This is a signed-installer model (download + run the new signed package), not in-place jar patching.
latest.json shape:
{
"version": "1.8.0",
"notesUrl": "https://github.com/Plus-Mobile-Apps/chef-mate/releases/tag/v1.8.0",
"downloads": {
"linux": "https://github.com/.../releases/download/v1.8.0/chef-mate_1.8.0_amd64.deb"
}
}Windows ships through the Microsoft Store as an MSIX package. This is the recommended Windows path because:
- The Store signs the package for free on ingestion — no code-signing certificate needed (no ~$10/month or ~$200+/year cert).
- The Store handles auto-updates — which is why the in-app updater no-ops on Windows.
- SmartScreen trusts Store apps, so users never see "unknown publisher" warnings.
When creating the product in Partner Center, choose "MSIX or PWA app", not "EXE or MSI app". The EXE/MSI option only provides a listing that points at your own self-hosted, self-signed installer — it gives you neither free signing nor Store-managed updates.
- In Partner Center, create a new MSIX product and reserve the app name.
- From the product's Product Identity page, record three values needed for the manifest:
- Package/Identity Name (e.g.
12345PlusMobileApps.ChefMate) - Publisher (the
CN=...string, e.g.CN=ABCD1234-...) - Publisher Display Name (e.g.
Plus Mobile Apps)
- Package/Identity Name (e.g.
- The first submission must be done manually in Partner Center to establish the listing. Automated submission (below) is for subsequent releases.
jpackage / Compose Desktop does not emit MSIX, so we package the unpackaged app image ourselves. Compose Desktop's createDistributable task produces the app image (app + bundled JRE) at:
client/composeApp/build/compose/binaries/main/app/Chef Mate/
The MSIX is then built by laying that directory next to an AppxManifest.xml and running makeappx (part of the Windows SDK, preinstalled on windows-latest runners). This is wired into desktop-release.yml — the manifest lives at packaging/windows/AppxManifest.xml and uses __TOKEN__ placeholders that the workflow substitutes at build time.
packaging/windows/AppxManifest.xml (the committed manifest uses tokens; the identity values come from Partner Center, and the version must be 4-part with a .0 revision):
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities">
<Identity Name="[PACKAGE_IDENTITY_NAME]"
Publisher="[CN=PUBLISHER_ID]"
Version="1.7.1.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Chef Mate</DisplayName>
<PublisherDisplayName>Plus Mobile Apps</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0"
MaxVersionTested="10.0.22621.0" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Applications>
<Application Id="ChefMate"
Executable="Chef Mate.exe"
EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Chef Mate"
Description="Chef Mate - Your AI Cooking Assistant"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png" />
</Application>
</Applications>
<Capabilities>
<!-- Win32 / Desktop Bridge apps require the runFullTrust restricted capability -->
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>runFullTrust is the restricted capability that lets a packaged Win32 app (which a bundled-JRE app is) run unsandboxed. The required logo assets (StoreLogo, Square150x150Logo, Square44x44Logo) are generated in CI by copying the existing app icon — replace these with correctly-sized PNGs before submitting for Store certification.
Build step (the Build app image for MSIX + Package MSIX steps on the Windows leg of desktop-release.yml). The packaging step substitutes the manifest tokens, copies the icon into the Assets, and packs with makeappx:
- name: Package MSIX (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
STORE_IDENTITY_NAME: ${{ secrets.STORE_IDENTITY_NAME }}
STORE_PUBLISHER: ${{ secrets.STORE_PUBLISHER }}
run: |
# resolve version (tag → x.y.z), substitute __IDENTITY_NAME__/__PUBLISHER__/__VERSION__,
# copy icon to Assets\{StoreLogo,Square150x150Logo,Square44x44Logo}.png, then:
$makeappx = (Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\makeappx.exe" |
Sort-Object FullName -Descending | Select-Object -First 1).FullName
& $makeappx pack /d "$img" /p "$env:RUNNER_TEMP\ChefMate.msix" /oBecause the MSIX is signed by the Store on submission, you do not sign it yourself in CI. (You only need a self-signed cert to sideload it for local testing.)
Yes — this is wired in via Microsoft's MSStore CLI, driven from the publish-store job in desktop-release.yml.
Auth setup (one-time):
- In Partner Center → Account settings → User management → Azure AD applications, add (or create) an Azure AD application and grant it the Manager role.
- Record the tenant ID, client ID, and create a client secret; grab the seller ID from Account settings. Store them as the
PARTNER_CENTER_*secrets listed above. - Set
STORE_PRODUCT_ID(andSTORE_IDENTITY_NAME/STORE_PUBLISHER). Thepublish-storejob no-ops whileSTORE_PRODUCT_IDis unset, so it's safe to leave dormant until you're ready.
The job (runs only on tag pushes; each step is gated on STORE_PRODUCT_ID):
publish-store:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: windows-latest
env:
STORE_PRODUCT_ID: ${{ secrets.STORE_PRODUCT_ID }}
steps:
- uses: actions/download-artifact@v4
if: env.STORE_PRODUCT_ID != ''
with: { name: desktop-windows-msix, path: msix }
- name: Setup MSStore CLI
if: env.STORE_PRODUCT_ID != ''
uses: microsoft/setup-msstore-cli@v1
- name: Configure MSStore CLI
if: env.STORE_PRODUCT_ID != ''
run: >
msstore reconfigure
--tenantId ${{ secrets.PARTNER_CENTER_TENANT_ID }}
--sellerId ${{ secrets.PARTNER_CENTER_SELLER_ID }}
--clientId ${{ secrets.PARTNER_CENTER_CLIENT_ID }}
--clientSecret ${{ secrets.PARTNER_CENTER_CLIENT_SECRET }}
- name: Publish to Microsoft Store
if: env.STORE_PRODUCT_ID != ''
shell: pwsh
run: |
$msix = (Get-ChildItem msix/*.msix | Select-Object -First 1).FullName
msstore publish -i $msix -id $env:STORE_PRODUCT_IDCaveats / things to verify on first run:
- The first submission must be made manually in Partner Center; the API updates an existing listing, it doesn't create one.
- Verify the
microsoft/setup-msstore-cliaction ref and themsstore publish -idvalue (Partner Center product ID vs. Store app ID) against the current MSStore CLI docs — the CLI is in preview and flags shift. - Store certification review still applies to each submission and can take hours to a day, so Store releases lag the GitHub Release / mobile releases.
- The MSIX
Identity/Versionis derived from the release tag, so it increments automatically with the version bump.
Status: the MSIX packaging steps and
publish-storejob are wired intodesktop-release.yml. The MSIX builds on every run (with placeholder identity until the Store secrets are set); submission only fires on tag pushes onceSTORE_PRODUCT_IDis configured. The unsigned.msiis still built as a fallback/sideload artifact.
macOS ships through the Mac App Store as a sandboxed .pkg uploaded to App Store Connect
(TestFlight → release). The direct Developer-ID DMG has been retired. Compose builds the Store .pkg
with macOS { appStore = true } in client/composeApp/build.gradle.kts (packageReleasePkg); the
macos job in desktop-release.yml runs fastlane mac release to sign, build, and upload it.
Bundle ID: com.plusmobileapps.chefmate.ChefMate — the same as the iOS app id, so macOS ships
under the one "Chef Mate" App Store record (Universal Purchase / a single product page across iOS +
Mac) rather than a separate listing. The App ID already exists from iOS; you enable the macOS
platform on the existing app record instead of creating a new one.
The App ID's iOS capabilities (App Groups, Associated Domains, share-extension/watch relationships) carry into the macOS App Store provisioning profile. That's normally fine — the profile is a superset and the Mac entitlements simply don't claim them — but it's worth confirming the profile builds cleanly on the first
mac certificatesrun.
Certificates & profile (fetched via Fastlane match, type: "appstore", platform: "macos",
from the same private certificates repo used for iOS):
- Mac App Distribution cert (CN
3rd Party Mac Developer Application: …) — signs the.appbundle. Must be this legacy cert, not the unified "Apple Distribution". Compose's signer only matches the legacy3rd Party Mac Developer Application:/Developer ID Application:names (true through Compose 1.12.0-beta), so an "Apple Distribution" cert fails withCould not find certificate … in keychain []. The lane passesgenerate_apple_certs: falseto match so it mints this legacy cert instead of the modern unified one. - Mac Installer Distribution cert (CN
3rd Party Mac Developer Installer: …) — signs the.pkginstaller (fetched via matchadditional_cert_types: ["mac_installer_distribution"]). - Mac App Store provisioning profile — embedded in the
.app. Themac releaselane passes its path to Compose viaMACOS_PROVISIONING_PROFILE/MACOS_RUNTIME_PROVISIONING_PROFILE.
Signing: The mac release lane resolves the bare cert name (org + team, no prefix) from the
3rd Party Mac Developer Application cert into MACOS_SIGN_IDENTITY. Compose prepends
3rd Party Mac Developer Application: to sign the app; jpackage prepends
3rd Party Mac Developer Installer: (via --mac-signing-key-user-name) to sign the .pkg. Passing
the bare name lets each tool add its own prefix. Local builds without these env vars stay unsigned.
Getting the legacy cert (one-time, fiddly). Apple no longer issues the "Mac App Distribution" cert through the automated flow — even with
generate_apple_certs: false, match reuses the unified "Apple Distribution" cert unless the legacy cert already exists in the repo. So it must be created by hand once and imported (see the one-time bootstrap below). After that,generate_apple_certs: falsemakes match resolve to it and generate a matching profile. The lane fails fast if the3rd Party Mac Developer Applicationcert is missing.
App Sandbox (mandatory): The Mac App Store requires App Sandbox. Entitlements live in
packaging/macos/entitlements.plist (app: sandbox + network.client + files.user-selected.read-write
- JVM hardened-runtime exceptions) and
packaging/macos/runtime-entitlements.plist(the nested JRE: sandbox-inherit + JVM exceptions), wired viaentitlementsFile/runtimeEntitlementsFile.
Known risk — validate before the first real submission. This is a bundled-JRE app that uses a JavaFX WebView (the recipe browser), native file dialogs, and Supabase networking. Sandboxed JVM/JavaFX apps are a known source of runtime breakage and App Store review rejection, and the
disable-library-validation/allow-unsigned-executable-memoryentitlements are scrutinized in review. Smoke-test the signed.pkgsandboxed (WebView loads, file open/save works, sign-in + sync work, no sandbox denials in Console.app) before submitting, and be prepared to iterate on the entitlements or face rejection.
One-time bootstrap:
- In App Store Connect → the existing "Chef Mate" app → add the macOS platform (Universal Purchase) so Mac builds land under the same record. No new app record or App ID is created — both already exist from iOS. The App ID needs no extra capabilities enabled (App Sandbox, network, and file access come from the build's entitlements, not the portal).
- Create the Mac App Distribution certificate by hand (match won't — see the note above):
- In Keychain Access → Certificate Assistant → Request a Certificate from a Certificate Authority ("Saved to disk"), generate a CSR (this also puts the private key in your login keychain).
- At developer.apple.com → Certificates → ➕ → Mac App Distribution, upload the CSR, download
the
.cer, and double-click it to install.
- Import that cert + its key into the match repo (its key isn't there yet since you made it by hand).
The key must be a bare PKCS#1 PEM (same conversion the Developer ID cert used — see below), then:
unset APP_STORE_CONNECT_API_KEY APP_STORE_CONNECT_API_KEY_ID APP_STORE_CONNECT_API_ISSUER_ID export MATCH_PASSWORD=<match repo passphrase> bundle exec fastlane match import --type appstore --platform macos \ --git_url git@github.com:Plus-Mobile-Apps/certificates.git # Certificate (.cer): the Mac App Distribution cert; Private Key (.p12): the converted key; # Provisioning Profile: leave empty (generated next).
- Generate the Mac App Store provisioning profile against the imported cert (re-export the
ASC_*values first, since step 3 unset them):Confirm the output installsbundle exec fastlane mac certificates force:true3rd Party Mac Developer Applicationand the profile's Certificate Name is that cert (not Apple Distribution). - From then on CI consumes the stored assets read-only inside
fastlane mac release.
Reused secrets: MATCH_PASSWORD, MATCH_GIT_BASIC_AUTHORIZATION, ASC_KEY_ID, ASC_ISSUER_ID, ASC_API_KEY, APPLE_TEAM_ID (no new secrets required).
Architecture — arm64 only (Intel Macs not supported). CI builds on an Apple-Silicon runner, so
jpackage produces an arm64-only app with a bundled arm64 JRE. App Store Connect rejects an
arm64-only Mac app unless it declares a minimum macOS of 12.0+, so macOS.minimumSystemVersion = "12.0" is set in build.gradle.kts (LSMinimumSystemVersion). Supporting Intel would require a
universal (arm64+x86_64) binary — a much larger change (build both arches + a universal JRE, lipo
them) that jpackage does not do natively.
Native libraries must be bundled + signed (App Store sandbox). A sandboxed App Store app can only
load code that's part of its signed bundle. sqlite-jdbc (via SQLDelight's JVM driver) otherwise
extracts its native libsqlitejdbc.dylib to a temp dir at runtime and dlopens it, which the
sandbox blocks at launch ("could not verify … free of malware"). The extractSqliteJdbcMacDylib task
in build.gradle.kts stages the arm64 .dylib into Compose's app resources so jpackage signs it
into the bundle, and DriverFactory.jvm.kt points sqlite-jdbc at it via org.sqlite.lib.path so it
loads the signed copy instead of extracting. Any other JVM library that extracts native code at
runtime will hit the same wall and need the same treatment.
Validating signing without a release. The macos job also runs on workflow_dispatch
(Actions → Desktop Release → Run workflow). A manual run builds + signs the .pkg and uploads it
as a desktop-macos-pkg artifact, but sets MAC_UPLOAD=false so it does not push to App Store
Connect — use it to verify signing (e.g. after a cert rotation) without cutting a version tag. Only
tag pushes set MAC_UPLOAD=true and upload. The build runs with -Pcompose.desktop.verbose=true so
jpackage's internal codesign errors are visible in the log (otherwise they surface only as an
opaque codesign … exited with 1 IOException).
Signing pitfalls that bit us (all handled by the mac release lane, noted here for cert
rotation): Compose only matches the legacy 3rd Party Mac Developer Application: cert name, so the
lane resolves the bare identity and needs the Mac App Distribution cert (not the unified
Apple Distribution — created + imported by hand, see bootstrap). Compose embeds the app profile via
jpackage --app-content, which (a) splits the path on whitespace in its @arg-file and (b) keeps the
filename — so the lane copies match's profile to a space-free path named exactly
embedded.provisionprofile before building.
Follow these steps in order to go from zero to automated Play Store deployments:
-
Generate a release keystore (see below), or use an existing one.
-
Create a
keystore.propertiesfile in the project root with your keystore details (see the signing configuration section above for the format). Use an absolute path forreleaseKeyStore. -
Create your app on Google Play Console — set up the app listing with the required store details (title, description, etc.).
-
Upload the first AAB manually — the Play Store API cannot create a new app, only upload to an existing one:
./gradlew :client:composeApp:bundleRelease
Then upload
client/composeApp/build/outputs/bundle/release/composeApp-release.aabthrough the Play Console. -
Create a Google Play service account (see below) and grant it access in the Play Console.
-
Test Fastlane locally to verify the service account works:
export SUPPLY_JSON_KEY_DATA=$(cat /path/to/service-account-key.json) bundle exec fastlane android release
-
Configure GitHub secrets — add all required secrets listed in the GitHub Secrets section above.
-
Trigger a release — commit your changes, create a tag, and push:
git tag v0.1.0 git push origin v0.1.0
-
After first publish — once the app is live on any track, update
release_statusinfastlane/Fastfilefrom"draft"to"completed"so future uploads go live automatically.
If you don't already have a release keystore:
keytool -genkeypair \
-alias release \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-keystore release.keystore \
-storepass YOUR_STORE_PASSWORD \
-keypass YOUR_KEY_PASSWORD \
-dname "CN=Chef Mate, O=Plus Mobile Apps"To base64-encode it for the GitHub secret:
base64 -i release.keystore | pbcopy # macOS (copies to clipboard)
base64 -w 0 release.keystore # Linux (prints to stdout)Store the output as the ANDROID_KEYSTORE_BASE64 secret.
- Go to the Google Cloud Console
- Create a service account with the "Service Account User" role
- Generate a JSON key for the service account
- In Google Play Console, go to Settings > API access and grant the service account access
- Paste the entire JSON key content as the
PLAY_STORE_SERVICE_ACCOUNT_JSONsecret
Important: The first AAB must be uploaded manually through the Play Console to create the app listing. Fastlane supply can only upload to an existing app.
Draft apps: If your app has not yet been published on any track, the Play Store API requires uploads to have release_status: "draft". This is configured in fastlane/Fastfile. Once the app is published for the first time (even to the internal track), you can change this to "completed" so releases go live automatically.
The certificates repo (Plus-Mobile-Apps/certificates) is configured in
fastlane/Matchfile. Match runs in readonly: true mode on CI, so the PAT
behind MATCH_GIT_BASIC_AUTHORIZATION only needs read access.
-
Create a private git repository under the org to store encrypted certificates (currently
Plus-Mobile-Apps/certificates). If you move it, updatefastlane/Matchfile. -
Run match locally to generate and store certificates:
bundle exec fastlane ios certificatesThis read-write lane authenticates with the App Store Connect API key (set the
APP_STORE_CONNECT_API_*env vars, same as the release lane), registers the watchOS companion's App ID viaproduceif it doesn't exist yet (it ships inside the parent app, so no App Store Connect record is created), and then provisions every app id infastlane/Matchfile— the app, the share extension, and the watchOS app (com.plusmobileapps.chefmate.ChefMate.watchkitapp). Re-run it after adding a new app id so its App Store profile is registered before the next release; the release lane / CI fetches profiles inreadonlymode and will fail if one is missing.Don't run
fastlane match appstoredirectly — on its own it doesn't build the API-key Hash and fails with'api_key' value must be a Hash. Use thecertificateslane, which sets it up. -
Set the
MATCH_PASSWORDsecret to the encryption password you chose.
MATCH_GIT_BASIC_AUTHORIZATION is not the raw PAT — it's a base64-encoded
username:token string that Fastlane passes as an HTTP Basic Auth header to
clone the cert repo. Rotate it whenever the underlying PAT expires.
-
Open https://github.com/settings/personal-access-tokens → Generate new token (fine-grained) with:
- Token name:
chef-mate match certificates (read) - Resource owner:
Plus-Mobile-Apps(the org owns the cert repo; a personally-owned token will hit403 Write access to repository not grantedeven on a clone). - Expiration: pick a date and calendar it.
- Repository access: Only select repositories →
Plus-Mobile-Apps/certificates. - Repository permissions → Contents: Read-only, Metadata: Read-only. Approve via org settings if pending.
- Token name:
-
Base64-encode
username:token. Useprintf, notecho— a trailing newline silently breaks Basic Auth:printf 'Plus-Mobile-Apps:<PAT>' | base64
The username field is essentially decorative for fine-grained PATs; what matters is that the token itself is valid.
-
Paste the entire base64 string into the
MATCH_GIT_BASIC_AUTHORIZATIONsecret at https://github.com/Plus-Mobile-Apps/chef-mate/settings/secrets/actions.
If the match step fails with Error cloning certificates git repo or
The requested URL returned error: 403, the PAT has expired, been revoked,
or was issued under the wrong Resource owner. Regenerate and re-encode.
- Go to App Store Connect > Users and Access > Integrations > App Store Connect API
- Create a new key with "App Manager" role
- Download the
.p8file (you can only download it once) - Base64-encode the key content:
base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy- Set the three secrets:
ASC_KEY_ID— the Key ID shown in App Store ConnectASC_ISSUER_ID— the Issuer ID shown at the top of the API keys pageASC_API_KEY— the base64-encoded.p8content
You can run the Fastlane lanes locally to test the build steps (uploading will require valid credentials).
If you have a keystore.properties file at the project root, no environment variables are needed for Android builds:
# Android — build the AAB locally (uses keystore.properties)
./gradlew :client:composeApp:bundleRelease
# iOS — run the full lane (requires match setup and Xcode)
bundle exec fastlane ios release
# Desktop — build release packages for your current OS (hides developer-only UI).
# Output lands in build/compose/binaries/main-release/. Use the plain package* tasks
# only for local debug builds where you want the Developer Settings row visible.
./gradlew :client:composeApp:packageReleasePkg # macOS (Mac App Store .pkg; unsigned without certs)
./gradlew :client:composeApp:packageReleaseMsi # Windows
./gradlew :client:composeApp:packageReleaseDeb # LinuxVersions are defined in two files across three platforms:
| Platform | File | Fields |
|---|---|---|
| Android | client/composeApp/build.gradle.kts |
versionCode / versionName |
| iOS | iosApp/Configuration/Config.xcconfig |
CURRENT_PROJECT_VERSION / MARKETING_VERSION |
| Desktop | client/composeApp/build.gradle.kts |
packageVersion (generic + macOS) |
The normal release path is fully automated and does not require running anything locally or creating tags by hand:
- Trigger the Bump Version workflow (
bump-version.yml) from the GitHub Actions UI (workflow_dispatch), choosingmajor/minor/patch/custom. - It updates all version fields (Android, iOS, Desktop) and opens a PR (
chore: bump version to X.Y.Z). - Merging that PR triggers
build-release.yml, which creates thevX.Y.Ztag and publishes the GitHub Release. - The tag push then triggers
android-release,desktop-release, andios-release(requiresRELEASE_TOKEN).
See ci/VERSION_BUMP_SETUP.md for the required RELEASE_TOKEN configuration.
For local bumps (e.g. when not using the CI workflow), scripts/bump-version.sh updates all version fields at once. Run it with no arguments for interactive mode:
./scripts/bump-version.shThis displays the current versions and presents four options:
- Increment build number only — keeps the current version, bumps the build code
- Bump patch — increments the patch version (e.g.
0.1.18→0.1.19) and build number - Custom version — prompts for a version (
X.Y.Z), auto-increments build number - Custom version and build number — prompts for both values
CLI flags are also available for non-interactive use:
./scripts/bump-version.sh --build # build number only
./scripts/bump-version.sh --version 0.2.0 # set version, auto-increment build
./scripts/bump-version.sh --version 1.0.0 --build-number 1 # set both- Windows MSI and Linux DEB are unsigned. macOS ships a Store-signed
.pkgto the Mac App Store (see macOS: Mac App Store) and Windows ships via the Microsoft Store (Store-signed), so this only affects the fallback MSI and the Linux DEB. - MSIX packaging logo assets are placeholders — CI reuses the app icon for all three Store logos, which won't pass Store certification. Replace with correctly-sized PNGs (see Windows: Microsoft Store (MSIX)).
- MSStore CLI is in preview — the
publish-storejob's action ref andpublishflags should be verified against current docs on first real submission. - Store releases lag — Microsoft Store certification review delays Windows releases relative to the GitHub Release and mobile stores.
- R8/ProGuard is disabled — Android release builds are not minified. Enabling R8 requires ProGuard rules for KMP libraries (Ktor, Supabase, kotlinx.serialization, Decompose).