Automate App Store releases via self-hosted Mac runner

Mirror the existing Android publishing flow for iOS. The pipeline
gains two jobs: build-ipa runs on a self-hosted Mac runner and
produces a signed App Store IPA; publish-app-store runs on a shared
Linux runner and submits the prebuilt IPA to App Store Connect.

Build pipeline (.gitlab-ci.yml):
- build-ipa (Mac, stage build, parallel with build-apk): decodes the
  ASC API key, runs match (with api_key, so cert validity is verified
  against Apple before xcodebuild starts), builds web assets, syncs
  Capacitor, stamps MARKETING_VERSION. Uploads Ditto-${CI_COMMIT_TAG}
  .ipa to GitLab's Generic Packages registry.
- publish-app-store (Linux ruby:3.3, needs: [build-ipa]): gem
  install fastlane, decode the ASC API key, extract the changelog
  section into release_notes.txt, fastlane submit_release with
  IPA_PATH pointing at the inherited artifact. No Xcode, no signing,
  no keychain \u2014 pure Apple API call.
- release job now needs both build-apk and build-ipa, and links three
  assets (APK / AAB / IPA).

fastlane (ios/fastlane/Fastfile, Matchfile, Appfile, metadata/):
- Four lanes: build_ipa (CI build), submit_release (CI publish, reads
  IPA_PATH from env), release (single-step convenience for local
  dev), submit_only (debug lane to re-submit an already-uploaded
  build).
- Match config points at the private gitlab.com/soapbox-pub
  /certificates repo. App Store Connect API key is built inline in
  the Fastfile to avoid a collision with match's APP_STORE_CONNECT
  _API_KEY_PATH env var (match wants a JSON descriptor, the action
  writes a raw .p8). CI overrides CODE_SIGN_STYLE=Manual via xcargs
  so the Xcode project can stay on Automatic for local development.

Vite config (vite.config.ts):
- Renames the build-time config override env var from CONFIG_FILE to
  DITTO_CONFIG_FILE. GitLab Runner sets CONFIG_FILE to its own TOML
  config in job env, which broke vite's loader.

App-side changes:
- ios/App/App.xcodeproj/project.pbxproj: team GZLTTH5DLM stamped in;
  MARKETING_VERSION gets stamped from the tag at build time.
- public/CHANGELOG.md, package.json: v2.14.3.

Skills + AGENTS.md updated to reflect the six-job pipeline (test /
deploy unchanged, build now has two jobs, release / publish updated)
and to document Mac-runner operations, fastlane match cert rotation,
and local debugging workflows.
This commit is contained in:
Alex Gleason
2026-05-11 12:59:04 -07:00
parent c85f65a99a
commit b8773c47d7
17 changed files with 732 additions and 21 deletions
+168 -3
View File
@@ -13,9 +13,9 @@ Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the
|-----------|---------------------------|-----------------------------------------|
| `test` | every commit (not tags) | `npm run test` |
| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) |
| `build` | tags only | `build-apk` (signed release APK + AAB) |
| `release` | tags only | GitLab Release with APK artifact |
| `publish` | tags only | `publish-zapstore` + `publish-google-play` |
| `build` | tags only | `build-apk` (signed APK + AAB) + `build-ipa` (signed IPA on the Mac runner) |
| `release` | tags only | GitLab Release with APK / AAB / IPA links |
| `publish` | tags only | `publish-zapstore` + `publish-google-play` + `publish-app-store` |
## Creating a Release
@@ -155,3 +155,168 @@ The `publish-google-play` CI job uploads Android AABs to [Google Play](https://p
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.).
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
## App Store Publishing
Ditto's iOS pipeline is split across two jobs:
- **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs.
- **`publish-app-store`** (stage `publish`, `image: ruby:3.3` on a shared Linux runner) consumes the IPA artifact via `needs: [build-ipa]`. Installs fastlane via `gem install`, decodes the API key, extracts the changelog section for the tag into `release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + select the prebuilt build + auto-submit for App Store review. No Xcode required, no signing in this job — it's just an Apple API call.
The Mac runner is therefore only used for `build-ipa`. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill.
**Configuration files:**
- `ios/fastlane/Fastfile` — exposes four lanes:
- `build_ipa` — setup_ci → match (readonly, with API key) → increment_build_number → build_app. Used by CI's `build-ipa`.
- `submit_release` — reads `IPA_PATH` env var, calls deliver against the prebuilt IPA. Used by CI's `publish-app-store`.
- `release` — combines build_ipa + submit_release; convenience for local one-shot runs.
- `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill.
- `ios/fastlane/Appfile` — bundle identifier and team ID
- `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo
- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it from `CHANGELOG.md` per release
- `.gitlab-ci.yml` — `build-ipa` (Mac runner, `tags: [macos]`) + `publish-app-store` (Linux runner)
**Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`.
**App Store Connect auth**: a long-lived [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) (`.p8` file + key ID + issuer ID) authenticates `match`, `deliver`, and `pilot`. Avoids 2FA prompts that would interrupt CI.
**Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`.
**Release notes**: extracted from `CHANGELOG.md` per tag using the same `awk` extraction as the GitLab `release` job, written to `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text.
**IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile).
**GitLab CI/CD variables:**
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `MATCH_PASSWORD` | Symmetric passphrase used by match to encrypt/decrypt certs and profiles. The single most important secret — losing it makes the cert repo unreadable. | Yes | Yes | Yes |
| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64 of `username:deploy-token` for HTTPS clone of the certificates repo. Generated from a `read_repository`-scoped deploy token on `soapbox-pub/certificates`. | Yes | Yes | Yes |
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect API key ID (10 chars). | Yes | No | Yes |
| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect issuer ID (UUID). | Yes | No | Yes |
| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Base64-encoded contents of the `.p8` private key file. CI decodes with `base64 -d` into `~/.private_keys/AuthKey_<KEY_ID>.p8` and removes it in `after_script`. | Yes | Yes | Yes |
| `FASTLANE_KEYCHAIN_PASSWORD` | Password for the ephemeral keychain `setup_ci` creates per build. Random per setup; keep stable across runs. | Yes | Yes | Yes |
### Initial setup (one-time)
1. **Provision the Mac runner.** See the **`mac-runner`** skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and `gitlab-runner` registration.
2. **Create the App Store Connect API key.** Log in to [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API → Generate. Use the **App Manager** role (sufficient for `deliver`'s upload + submit-for-review). Download the `.p8` file (one-time download — Apple won't show it again). Note the **Key ID** (10-char string next to the key) and the **Issuer ID** (UUID at the top of the API page).
Set the three GitLab CI variables:
```bash
# Replace <ISSUER_ID>, <KEY_ID>, and the path to your .p8
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \
--data-urlencode "key=APP_STORE_CONNECT_API_KEY_ISSUER_ID" \
--data-urlencode "value=<ISSUER_ID>" \
--data-urlencode "protected=true" --data-urlencode "raw=true"
# repeat for APP_STORE_CONNECT_API_KEY_ID
# for the .p8, base64 first:
base64 -i AuthKey_<KEY_ID>.p8 | tr -d '\n' # paste this as APP_STORE_CONNECT_API_KEY_P8_BASE64 (masked)
```
3. **Create the certificates repo.** A private GitLab repo at `soapbox-pub/certificates` holds match-encrypted certs/profiles. Create a project deploy token on it (Settings → Repository → Deploy tokens) with `read_repository` scope. Encode `username:token` as base64 → set as `MATCH_GIT_BASIC_AUTHORIZATION` (protected, masked, raw).
4. **Generate `MATCH_PASSWORD` and `FASTLANE_KEYCHAIN_PASSWORD`.** Both are arbitrary strong random strings — `openssl rand -base64 32 | tr -d '=+/' | head -c 32` works. Store them as protected, masked GitLab variables.
5. **Bootstrap match certs via a one-shot CI job** (preferred over running match locally — avoids the macOS keychain UI permission dialogs that fastlane bug [#15185](https://github.com/fastlane/fastlane/issues/15185) trips on newer macOS):
a. Create a temporary write-scoped GitLab variable. The deploy token is `read_repository`; for the initial cert creation match needs to push. Encode `username:write-pat` as base64 and set it as `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` (Protected, Masked, Raw).
b. Add a temporary `setup-match` job to `.gitlab-ci.yml` that runs on the macos runner with `setup_ci` (which creates an ephemeral keychain — bypasses the GUI permission issue):
```yaml
setup-match:
stage: publish
tags: [macos]
rules:
- if: $SETUP_MATCH == "1"
when: manual
script:
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
- mkdir -p "$HOME/.private_keys" && chmod 700 "$HOME/.private_keys"
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
- chmod 600 "$ASC_KEY_PATH"
- cd ios
- export MATCH_GIT_BASIC_AUTHORIZATION="$MATCH_GIT_BASIC_AUTHORIZATION_WRITE"
- unset APP_STORE_CONNECT_API_KEY_PATH || true
- |
cat > Fastfile.setup <<'RUBY'
default_platform(:ios)
platform :ios do
lane :setup do
setup_ci
api_key = {
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
duration: 1200,
in_house: false,
}
match(type: "appstore", readonly: false, api_key: api_key, force_for_new_devices: true)
end
end
RUBY
- mv fastlane/Fastfile fastlane/Fastfile.bak
- mv Fastfile.setup fastlane/Fastfile
- fastlane setup
- mv fastlane/Fastfile.bak fastlane/Fastfile
after_script:
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
```
c. Trigger the pipeline manually with `SETUP_MATCH=1`:
```bash
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline" \
--data-urlencode "ref=main" \
--data-urlencode "variables[][key]=SETUP_MATCH" \
--data-urlencode "variables[][value]=1"
# Then play the manual setup-match job
```
d. Once the job succeeds (cert + profile pushed to the certificates repo), **delete the `setup-match` job from `.gitlab-ci.yml` and the `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` variable**. They're only needed for bootstrap.
### Yearly cert renewal
Apple distribution certs expire annually. Renewal is one command per year, run on any Mac:
```bash
cd ~/Projects/ditto/ios
fastlane match nuke distribution # revokes old cert in Apple's portal, removes from match repo
fastlane match appstore # creates new cert + profile, encrypts, commits, pushes
```
CI's next tag run picks up the new files automatically (`match(... readonly: true)`).
### Disaster recovery (Mac dies / new developer joins)
```bash
git clone https://gitlab.com/soapbox-pub/ditto.git
cd ditto/ios
fastlane match appstore --readonly # decrypts existing certs/profiles using MATCH_PASSWORD
```
No re-issuance of certs needed — the cert repo is the source of truth.
### App Store Connect API key rotation
App Store Connect API keys can be revoked anytime. To rotate:
1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key
2. Download the new `.p8`, note the new key ID
3. Update `APP_STORE_CONNECT_API_KEY_ID` and `APP_STORE_CONNECT_API_KEY_P8_BASE64` in GitLab variables
4. (Issuer ID stays the same — it's per-team, not per-key)
5. Revoke the old key in App Store Connect
### Key points
- `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (Linux) submits it to Apple via `deliver`.
- Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI.
- Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`.
- Release notes ("What's New in This Version") are auto-extracted from `CHANGELOG.md` and uploaded by `deliver`.
- `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in.
- `publish-app-store` doesn't sign anything, so it doesn't need macOS or a keychain — pure Apple API call.
+249
View File
@@ -0,0 +1,249 @@
---
name: mac-runner
description: Operate the self-hosted GitLab Runner on the Mac that builds Ditto's iOS IPA. Covers SSH access, restarting the runner, viewing logs, updating Xcode, debugging fastlane locally, and rotating match certificates.
---
# Mac Runner Operations
Ditto's iOS pipeline splits into two CI jobs: `build-ipa` runs on a self-hosted GitLab Runner on a MacBook in the rack (Xcode + signing only run on macOS), and `publish-app-store` runs on a shared Linux runner (no signing — just an Apple API call). This skill covers operating the Mac.
This skill covers operating the runner: SSH access, restarting after crashes or Xcode updates, watching logs, debugging fastlane locally, and rotating the match certificates. For initial provisioning, App Store Connect API key creation, and GitLab CI variable setup, load the **`ci-cd-publishing`** skill.
## Quick reference
| Need | Command |
|---|---|
| SSH in | `ssh alex@alexs-air.lan` |
| Runner status | `gitlab-runner status` |
| Restart runner | `gitlab-runner restart` (after `eval "$(/opt/homebrew/bin/brew shellenv)"`) |
| Stdout log | `tail -f ~/gitlab-runner.out.log` |
| Stderr log | `tail -f ~/gitlab-runner.err.log` |
| Runner config | `~/.gitlab-runner/config.toml` |
| LaunchAgent plist | `~/Library/LaunchAgents/gitlab-runner.plist` |
## Architecture
- **Host**: `alexs-air.lan` (Apple Silicon MacBook, macOS 26+, Xcode 26+)
- **User**: `alex` (the runner runs in user-mode so it can access keychain and Xcode UI tooling)
- **Tooling**: Homebrew (`/opt/homebrew`), `gitlab-runner`, `node@22`, `ruby@3.3`, fastlane installed as a user gem under `~/.gem/ruby/3.3.0/`
- **Service**: launchd LaunchAgent at `~/Library/LaunchAgents/gitlab-runner.plist`. `KeepAlive=true` (auto-restart on crash) and `RunAtLoad=true` (starts on login). The agent loads when `alex` logs in via auto-login at boot.
- **Tags**: `macos`, `ios`, `xcode` — only the `build-ipa` job in `.gitlab-ci.yml` targets this runner. The `publish-app-store` job runs on a shared Linux runner (no signing or Xcode needed there — it's a pure Apple API call).
- **Shell setup**: `~/.bash_profile` sources brew shellenv and prepends `~/.gem/ruby/3.3.0/bin` and `/opt/homebrew/opt/ruby@3.3/bin` to `PATH` so `bash --login` (the runner's executor) finds fastlane + ruby 3.3.
### Why Ruby 3.3, not the brewed 4.0
Brewed `fastlane` (current version) ships running on Ruby 4.0 from `brew install ruby`. Ruby 4.0's OpenSSL bindings hit fastlane bug [#20553](https://github.com/fastlane/fastlane/issues/20553) — `OpenSSL::PKey::EC.new(pem)` raises "invalid curve name" for `prime256v1` keys, which breaks every App Store Connect API key signing operation. Ruby 3.3.x doesn't have this bug. So we install fastlane via `gem install fastlane --user-install` on `ruby@3.3` instead of `brew install fastlane`.
### Why IPv6 is disabled on Wi-Fi
`networksetup -setv6off Wi-Fi` is set because Ruby's net/http on this machine attempted IPv6 to `rubygems.org` first and timed out (~30 s per request). Disabling IPv6 on the Wi-Fi interface forces IPv4 immediately. To re-enable: `sudo networksetup -setv6automatic Wi-Fi`.
## Verifying the runner is healthy
From any machine:
```bash
curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/runners/53111580" \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['status'], d['online'])"
```
Expected: `online True`. If `offline` or `not_connected`, SSH in and check:
```bash
ssh alex@alexs-air.lan
gitlab-runner status
ps aux | grep gitlab-runner
tail -50 ~/gitlab-runner.err.log
```
## Restarting the runner
After a Mac reboot, the runner should start automatically via the LaunchAgent. To restart manually:
```bash
ssh alex@alexs-air.lan
eval "$(/opt/homebrew/bin/brew shellenv)"
gitlab-runner restart
```
If `gitlab-runner restart` reports "service not installed", reinstall:
```bash
gitlab-runner install
gitlab-runner start
```
This rewrites the LaunchAgent plist.
## Watching a CI job run live
```bash
ssh alex@alexs-air.lan 'tail -f ~/gitlab-runner.out.log'
```
The runner streams build output to stdout. The same output appears in the GitLab job UI.
## Updating Xcode
After a major Xcode update:
```bash
ssh alex@alexs-air.lan
sudo xcodebuild -license accept # accept the new license non-interactively
xcode-select --install # ensure command-line tools are present
xcodebuild -version # confirm version
```
Then trigger a no-op tag rebuild (e.g. cut a patch release) to verify the runner still works.
## Debugging fastlane locally
If `build-ipa` fails in CI, reproduce on the Mac. The env vars below mirror what CI sets up:
```bash
ssh alex@alexs-air.lan
cd ~/Projects/ditto
git pull origin main
eval "$(/opt/homebrew/bin/brew shellenv)"
# Match what CI provides
export CI_COMMIT_TAG=v2.x.y
export CI_PIPELINE_IID=99999
export MATCH_PASSWORD='<from GitLab CI variables>'
export MATCH_GIT_BASIC_AUTHORIZATION='<base64 of ci-readonly:gldt-...>'
export APP_STORE_CONNECT_API_KEY_ID=<key-id>
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<issuer-id>
export ASC_KEY_PATH=~/.private_keys/AuthKey_<key-id>.p8
# Build web assets and sync to Capacitor iOS project (CI does this in before_script)
npm ci
npx vite build -l error
cp dist/index.html dist/404.html
npx cap sync ios
node scripts/patch-cap-config.mjs
# Stamp marketing version (CI does this in script)
VERSION="${CI_COMMIT_TAG#v}"
sed -i '' "s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g" ios/App/App.xcodeproj/project.pbxproj
# Run the build lane
cd ios
fastlane build_ipa
```
This produces the IPA at `../artifacts/Ditto.ipa` exactly like CI. Add `--verbose` for detailed output.
To also test the submission step end-to-end (this calls Apple, so be ready to "Remove from Review" in App Store Connect afterward):
```bash
export IPA_PATH="$HOME/Projects/ditto/artifacts/Ditto.ipa"
fastlane submit_release
```
Or, to debug *just* the submission against an already-uploaded build without rebuilding, use the `submit_only` lane (see "Debugging App Store submission with the `submit_only` lane" below).
## Rotating match certificates (yearly)
Apple distribution certs expire one year after issuance. To renew:
```bash
ssh alex@alexs-air.lan
cd ~/Projects/ditto/ios
eval "$(/opt/homebrew/bin/brew shellenv)"
# Set Apple credentials (API key path)
export MATCH_PASSWORD='<from GitLab CI variables>'
# Revoke the expiring cert in Apple's portal and remove from the match repo
fastlane match nuke distribution
# Issue a new cert, generate a new App Store profile, encrypt, commit, push
fastlane match appstore \
--api_key_path ~/.private_keys/AuthKey_<KEY_ID>.p8 \
--api_key_id <KEY_ID> \
--api_issuer_id <ISSUER_ID>
```
CI's next tag run picks up the new files via `match(... readonly: true)`. No GitLab variables to update.
## Debugging App Store submission with the `submit_only` lane
The `Fastfile` exposes a second lane, `submit_only`, that skips build/archive/upload and just runs `deliver` against an already-uploaded build. Useful when the binary is fine but the metadata/submission step is failing — iterate in ~30 seconds instead of waiting for a full ~6-minute CI build.
```bash
ssh alex@alexs-air.lan
export PATH="$HOME/.gem/ruby/3.3.0/bin:/opt/homebrew/opt/ruby@3.3/bin:$PATH"
cd ~/Projects/ditto/ios
# Make sure the .p8 is on disk; CI's after_script wipes it after each job
scp $LAPTOP:/path/to/AuthKey_<KEY_ID>.p8 ~/.private_keys/
export ASC_KEY_PATH=$HOME/.private_keys/AuthKey_<KEY_ID>.p8
export APP_STORE_CONNECT_API_KEY_ID=<KEY_ID>
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<ISSUER_ID>
export BUILD_NUMBER=<existing-build-number-on-ASC>
export VERSION=<marketing-version, e.g. 2.14.3>
fastlane submit_only
```
The lane expects the version to exist in App Store Connect with a `VALID` build attached. It uploads metadata (`./fastlane/metadata/en-US/release_notes.txt`) and calls `submit_for_review`. If Apple rejects, fix the Fastfile, re-run — no rebuild needed.
If Apple has already accepted the submission for that version, you'll need to "Remove from Review" in App Store Connect (only available while state is `WAITING_FOR_REVIEW`, not `IN_REVIEW`) before re-running, or bump the build number.
## Inspecting App Store Connect state directly
When fastlane's error messages aren't enough, query Apple's API directly. There's no installed CLI — use the JWT signing recipe Apple documents. A working Ruby snippet lives in this skill's troubleshooting history; the short version:
```ruby
require "json"; require "openssl"; require "net/http"; require "base64"
key_pem = File.read(ENV["ASC_KEY_PATH"])
ec = OpenSSL::PKey::EC.new(key_pem)
header = { alg: "ES256", kid: ENV["APP_STORE_CONNECT_API_KEY_ID"], typ: "JWT" }
payload = { iss: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], iat: Time.now.to_i, exp: Time.now.to_i + 1200, aud: "appstoreconnect-v1" }
def b64(s); Base64.urlsafe_encode64(s, padding: false); end
si = b64(JSON.generate(header)) + "." + b64(JSON.generate(payload))
sig_der = ec.sign(OpenSSL::Digest::SHA256.new, si)
asn = OpenSSL::ASN1.decode(sig_der)
r = asn.value[0].value.to_s(2); s = asn.value[1].value.to_s(2)
r = ("\x00".b * (32 - r.bytesize)) + r if r.bytesize < 32
s = ("\x00".b * (32 - s.bytesize)) + s if s.bytesize < 32
jwt = si + "." + b64(r + s)
# Now: GET https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=pub.ditto.app
# with header Authorization: Bearer <jwt>
```
Useful endpoints:
- `GET /v1/apps?filter[bundleId]=pub.ditto.app` → app id
- `GET /v1/apps/<id>/appStoreVersions` → version list with `appStoreState`
- `GET /v1/apps/<id>/builds?sort=-uploadedDate` → recent builds and processing state
- `GET /v1/appStoreVersions/<id>/appStoreVersionLocalizations` → release notes (`whatsNew`)
## What can go wrong
| Symptom | Likely cause | Fix |
|---|---|---|
| Runner shows offline in GitLab | Mac rebooted, auto-login disabled, or LaunchAgent unloaded | SSH in, `gitlab-runner status`, `gitlab-runner restart` |
| Build fails: "unable to find Xcode" | Xcode auto-updated and changed path, or command-line tools missing | `xcode-select --install`, `sudo xcodebuild -license accept` |
| Build fails: "no signing certificate found" | match cert expired, was revoked manually, or `MATCH_PASSWORD` mismatched | Run yearly rotation procedure above |
| Build fails: keychain locked / "User interaction is not allowed" | `setup_ci` failed to create the temporary keychain | Verify `FASTLANE_KEYCHAIN_PASSWORD` is set in GitLab CI variables |
| Build fails: ASC API key invalid | Key was revoked or rotated | Generate a new key and update `APP_STORE_CONNECT_API_KEY_*` variables |
| "Build already exists" from `deliver` | Previous tag's IPA had the same `CFBundleVersion`; fastlane's `increment_build_number` didn't bump because the value already matched `CI_PIPELINE_IID` | Push a new tag (each new tag has a new pipeline ID) |
| Apple precheck rejects metadata | Encryption export compliance, IDFA, content rights flags don't match `Fastfile` | Update `submission_information` in `ios/fastlane/Fastfile` |
| `OpenSSL::PKey::PKeyError: invalid curve name` | fastlane is running on brewed Ruby 4.0, which has a broken OpenSSL EC parser ([fastlane#20553](https://github.com/fastlane/fastlane/issues/20553)) | Use `ruby@3.3` from brew and install fastlane as a user gem (`gem install fastlane --user-install`); ensure `~/.bash_profile` puts `~/.gem/ruby/3.3.0/bin` on PATH ahead of `/opt/homebrew/bin` |
| `gem install` / `bundle install` hangs for >30s per request | Ruby's net/http tries IPv6 to rubygems.org and times out on this network | `sudo networksetup -setv6off Wi-Fi` (per-interface, persistent until reboot) |
| `Unresolved conflict between options: 'api_key_path' and 'api_key'` | `app_store_connect_api_key` action sets `APP_STORE_CONNECT_API_KEY_PATH` env var (path to `.p8`), match's same-named env var expects a JSON descriptor | Build the API key hash inline in the Fastfile (don't call `app_store_connect_api_key`); read `.p8` from a non-conflicting var like `ASC_KEY_PATH` |
| `[match] Could not find the newly generated certificate installed` when running match interactively on macOS 26+ | [fastlane#15185](https://github.com/fastlane/fastlane/issues/15185) — the new-cert verification step trips on partition list and keychain trust | Run cert generation **in CI** via the bootstrap procedure in the `ci-cd-publishing` skill (uses `setup_ci`'s ephemeral keychain). Don't run `fastlane match appstore` interactively. |
| iOS build fails: `No "iOS Development" signing certificate matching team ID` | The Xcode project uses `CODE_SIGN_STYLE=Automatic`; xcodebuild tries to find a Development cert even for Release builds | Override via `xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore <bundle-id>' DEVELOPMENT_TEAM=<team>"` in the Fastfile (already configured) |
| `vite.config.ts: Unexpected token 'c', "concurrent"... is not valid JSON` | GitLab Runner sets `CONFIG_FILE=/Users/alex/.gitlab-runner/config.toml` in the job environment, which collides with vite's `process.env.CONFIG_FILE ?? "./ditto.json"` lookup | Already fixed: use `DITTO_CONFIG_FILE` for the override env var |
| `whatsNew is missing` from `submit_for_review` | `metadata_path: "./metadata"` resolves relative to fastlane's cwd (`ios/`), not its config dir (`ios/fastlane/`); fastlane silently uploads zero locales | Use `metadata_path: "./fastlane/metadata"` (already configured) |
| `appStoreVersions ... is not in valid state` | Apple won't accept submission because the version is past `PREPARE_FOR_SUBMISSION` (already submitted, in review, or shipped) | "Remove from Review" in App Store Connect if `WAITING_FOR_REVIEW`, or cut a new version |
| `An attribute value is not acceptable for the current resource state. - contentRightsDeclaration` | Apple rejects PATCH on locked App-level fields when `submission_information` includes `content_rights_*` | Drop `content_rights_*` from `submission_information` in the Fastfile (already configured) |
## When the Mac dies
1. Get a replacement Mac. Install Xcode from the App Store.
2. Run the **`ci-cd-publishing`** skill's "Initial setup" — but skip the App Store Connect API key step (you already have it). Re-register the runner with the same `macos` tag.
3. Restore signing identity: `cd ditto/ios && fastlane match appstore --readonly` decrypts the existing certs/profiles using `MATCH_PASSWORD`.
4. No reissuance, no revocation, no GitLab variable updates needed. The certificates repo is the source of truth.
+11 -5
View File
@@ -265,8 +265,11 @@ git push origin main vX.Y.Z
This triggers the GitLab CI pipeline which will:
1. Build a signed Android APK and AAB
2. Create a GitLab Release with download links
3. Publish the APK to Zapstore
2. Build a signed iOS IPA on the self-hosted Mac runner
3. Create a GitLab Release with APK / AAB / IPA download links
4. Publish the APK to Zapstore
5. Publish the AAB to Google Play (production track)
6. Submit the iOS IPA to App Store Connect for review
### Step 12: Confirm
@@ -287,11 +290,14 @@ After pushing, inform the user:
## CI Pipeline
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs:
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs six jobs:
1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build
2. **release**: Creates a GitLab Release with the changelog content and download links
3. **publish-zapstore**: Publishes the APK to Zapstore
2. **build-ipa**: Builds the signed App Store IPA on the self-hosted Mac runner (`tags: [macos]`); stamps `MARKETING_VERSION` and `CFBundleVersion` into the Xcode project. The IPA is uploaded to GitLab's Generic Packages registry and exposed as a CI artifact for downstream jobs
3. **release**: Creates a GitLab Release with the changelog content and APK / AAB / IPA download links
4. **publish-zapstore**: Publishes the APK to Zapstore
5. **publish-google-play**: Uploads the AAB to Google Play production track
6. **publish-app-store**: Submits the prebuilt IPA to App Store Connect for review (runs on a shared Linux runner; no Xcode needed since the IPA is already built). The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill.
## Troubleshooting
+123
View File
@@ -179,11 +179,83 @@ build-apk:
- android/.gradle/
- .gradle/
build-ipa:
stage: build
tags:
- macos
timeout: 20 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_SKIP_UPDATE_CHECK: "1"
before_script:
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
- node --version
- ruby --version
- fastlane --version | head -3
# Decode the App Store Connect API key (.p8) into a private location.
# The Fastfile reads this directly via File.binread. We pass the API
# key into match so it contacts Apple's portal to verify the cert is
# still valid for the team — fails fast on a revoked / expired cert.
- mkdir -p "$HOME/.private_keys"
- chmod 700 "$HOME/.private_keys"
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
- chmod 600 "$ASC_KEY_PATH"
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
# a JSON descriptor; we pass the API key inline via the Fastfile.
- unset APP_STORE_CONNECT_API_KEY_PATH || true
# Build web assets and sync to Capacitor iOS project
- npm ci
- npx vite build -l error
- cp dist/index.html dist/404.html
- npx cap sync ios
- node scripts/patch-cap-config.mjs
script:
# Stamp marketing version from the git tag (e.g. v2.1.0 -> 2.1.0)
- VERSION="${CI_COMMIT_TAG#v}"
- echo "Building iOS version $VERSION (build ${CI_PIPELINE_IID}) from tag $CI_COMMIT_TAG"
- >-
/usr/bin/sed -i ''
"s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g"
ios/App/App.xcodeproj/project.pbxproj
# Run match (cert verify + decrypt) and build_app to produce the IPA.
# build_app writes ./artifacts/Ditto.ipa relative to the project root.
- cd ios
- fastlane build_ipa
- cd ..
# Move the IPA to a stable name in the artifact directory.
- ls -lh artifacts/
- test -f artifacts/Ditto.ipa
# Upload to the Generic Packages registry for a stable public download URL,
# mirroring how build-apk publishes the APK and AAB.
- |
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--upload-file "artifacts/Ditto.ipa" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa"
after_script:
# Wipe the API key so nothing sensitive sticks around between jobs.
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
artifacts:
paths:
- artifacts/Ditto.ipa
expire_in: 90 days
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
needs:
- build-apk
- build-ipa
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
@@ -209,6 +281,9 @@ release:
- name: Ditto-${CI_COMMIT_TAG}.aab
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.aab
link_type: package
- name: Ditto-${CI_COMMIT_TAG}.ipa
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa
link_type: package
publish-zapstore:
stage: publish
@@ -264,3 +339,51 @@ publish-google-play:
# Clean up
- rm -f /tmp/play-service-account.json
publish-app-store:
stage: publish
image: ruby:3.3
needs:
- build-ipa
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
FASTLANE_HIDE_CHANGELOG: "1"
FASTLANE_SKIP_UPDATE_CHECK: "1"
before_script:
- gem install fastlane --no-document
- fastlane --version | head -3
# Decode the App Store Connect API key (.p8) into a private location.
# The Fastfile reads this directly via File.binread.
- mkdir -p "$HOME/.private_keys"
- chmod 700 "$HOME/.private_keys"
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
- chmod 600 "$ASC_KEY_PATH"
script:
- VERSION="${CI_COMMIT_TAG#v}"
- test -f artifacts/Ditto.ipa
# Extract the changelog section for this version into release_notes.txt.
# Mirrors the awk extraction used by the GitLab `release` job for Android.
- mkdir -p ios/fastlane/metadata/en-US
- >-
awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md
| awk 'NF {p=1} p' > ios/fastlane/metadata/en-US/release_notes.txt
- |
if [ ! -s ios/fastlane/metadata/en-US/release_notes.txt ]; then
echo "Ditto ${CI_COMMIT_TAG}" > ios/fastlane/metadata/en-US/release_notes.txt
fi
- echo "--- release_notes.txt ---"
- cat ios/fastlane/metadata/en-US/release_notes.txt
- echo "-------------------------"
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Ditto.ipa"
- cd ios
- fastlane submit_release
after_script:
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
+4 -4
View File
@@ -351,10 +351,10 @@ Ditto uses GitLab CI (`.gitlab-ci.yml`) with five stages:
1. **test**`npm run test` on every commit (skipped for tags).
2. **deploy**`deploy-nsite` builds and uploads `dist/` to nsite via nsyte (default branch only).
3. **build**`build-apk` produces a signed release APK and AAB (tags only).
4. **release** — creates a GitLab Release with the APK artifact (tags only).
5. **publish**`publish-zapstore` (APK → Zapstore) and `publish-google-play` (AAB → Google Play production track), tags only.
3. **build**`build-apk` produces a signed APK and AAB (Linux); `build-ipa` produces a signed IPA on the self-hosted Mac runner. Both run on tags only.
4. **release** — creates a GitLab Release with the APK, AAB, and IPA artifacts (tags only).
5. **publish**`publish-zapstore` (APK → Zapstore), `publish-google-play` (AAB → Google Play), and `publish-app-store` (IPA → App Store Connect, runs on a shared Linux runner since the IPA is already signed in `build-ipa`), tags only.
To cut a release, load the **`release`** skill — it walks through version bumping (`X.Y.Z`), changelog generation, native build-file updates, and tagging/pushing (`vX.Y.Z`) to trigger the CI pipeline.
For CI credential setup and rotation (Zapstore NIP-46 bunker, nsyte `nbunksec`, Google Play service-account JSON, Android keystore), load the **`ci-cd-publishing`** skill.
For CI credential setup and rotation (Zapstore NIP-46 bunker, nsyte `nbunksec`, Google Play service-account JSON, Android keystore, App Store Connect API key, fastlane match), load the **`ci-cd-publishing`** skill. For Mac runner operations (SSH access, restarting, debugging fastlane locally, yearly cert rotation), load the **`mac-runner`** skill.
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## [2.14.3] - 2026-05-11
### Changed
- Behind-the-scenes maintenance release. No user-facing changes.
## [2.14.2] - 2026-05-11
### Added
+1 -1
View File
@@ -81,7 +81,7 @@ Configuration is resolved in three layers (highest priority first):
2. **Build config** from `ditto.json`
3. **Hardcoded defaults**
Use an alternate config file path with: `CONFIG_FILE=./my-config.json npm run build`
Use an alternate config file path with: `DITTO_CONFIG_FILE=./my-config.json npm run build`
### Custom Branding
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.14.2"
versionName "2.14.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -323,7 +323,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.14.2;
MARKETING_VERSION = 2.14.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -347,7 +347,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.14.2;
MARKETING_VERSION = 2.14.3;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+2
View File
@@ -0,0 +1,2 @@
app_identifier("pub.ditto.app")
team_id("GZLTTH5DLM")
+146
View File
@@ -0,0 +1,146 @@
default_platform(:ios)
platform :ios do
# ─── Lanes ────────────────────────────────────────────────────────────
desc "Build and sign the App Store IPA. Output at ../artifacts/Ditto.ipa."
lane :build_ipa do
setup_lane_signing!
build_release_ipa!
end
desc "Submit an already-built IPA to App Store Connect for review. " \
"Set IPA_PATH to the IPA's location."
lane :submit_release do
ipa_path = ENV.fetch("IPA_PATH") do
UI.user_error!("submit_release requires the IPA_PATH env var")
end
UI.user_error!("IPA not found at #{ipa_path}") unless File.exist?(ipa_path)
submit_release_for_review!(ipa_path)
end
desc "Build, sign, and submit Ditto to the App Store for review (single-step convenience)."
lane :release do
setup_lane_signing!
build_release_ipa!
# Use the IPA path set by build_app rather than recomputing it from
# __dir__, which gets fragile across fastlane-relative paths.
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
UI.user_error!("build_app did not set IPA_OUTPUT_PATH") unless ipa_path
submit_release_for_review!(ipa_path)
end
desc "Submit an already-uploaded build for review (skip build/upload). " \
"Use BUILD_NUMBER and VERSION env vars."
lane :submit_only do
submit_release_for_review!(nil)
end
# ─── Helpers ──────────────────────────────────────────────────────────
def setup_lane_signing!
# Create an ephemeral keychain so we never touch the login keychain.
setup_ci
api_key = build_api_key!
# Fetch encrypted distribution cert + provisioning profile from the
# shared certificates repo. --readonly: never mutate from CI.
# Passing api_key makes match contact Apple's portal to verify the
# cert is still valid for the team — fails fast on revoked/expired
# certs instead of letting xcodebuild stumble later.
match(type: "appstore", readonly: true, api_key: api_key)
api_key
end
def build_api_key!
# Build the API key hash inline. We avoid the app_store_connect_api_key
# action because it sets APP_STORE_CONNECT_API_KEY_PATH (path to the .p8)
# which collides with match's APP_STORE_CONNECT_API_KEY_PATH (path to a
# JSON descriptor). Same env name, different formats.
@api_key ||= {
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
duration: 1200,
in_house: false,
}
end
def build_release_ipa!
# Stamp build number from CI pipeline ID so every release is monotonically increasing.
increment_build_number(
xcodeproj: "App/App.xcodeproj",
build_number: ENV.fetch("CI_PIPELINE_IID"),
)
# Marketing version is set externally (sed in CI) before this lane runs.
build_app(
project: "App/App.xcodeproj",
scheme: "App",
configuration: "Release",
export_method: "app-store",
output_directory: "../artifacts",
output_name: "Ditto.ipa",
clean: true,
# Override the Xcode project's Automatic signing for this build only.
# Match has already installed the AppStore cert + profile into the
# ephemeral keychain; tell xcodebuild to use them explicitly so it
# doesn't also try to find an iOS Development cert (which we never
# provision in CI).
xcargs: [
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY='Apple Distribution'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore pub.ditto.app'",
"DEVELOPMENT_TEAM=GZLTTH5DLM",
].join(" "),
export_options: {
method: "app-store",
signingStyle: "manual",
teamID: "GZLTTH5DLM",
provisioningProfiles: {
"pub.ditto.app" => "match AppStore pub.ditto.app",
},
},
)
end
# If ipa_path is nil, deliver picks up the latest processed build for the
# configured app version (used by the submit_only lane).
def submit_release_for_review!(ipa_path)
api_key = build_api_key!
options = {
api_key: api_key,
submit_for_review: true,
automatic_release: false,
force: true,
precheck_include_in_app_purchases: false,
# Don't try to PATCH content rights on every submit — Apple's API
# rejects updates to contentRightsDeclaration once the listing has
# an established state. The values stay as set in the App Store
# Connect UI / from a prior submission.
submission_information: {
export_compliance_uses_encryption: false,
},
skip_screenshots: true,
# Keep skip_app_version_update=false: deliver needs to PATCH the
# version's whatsNew (release notes) and platform-version metadata
# before submit_for_review will accept the version.
skip_app_version_update: false,
skip_metadata: false,
metadata_path: "./fastlane/metadata",
run_precheck_before_submit: false,
}
options[:ipa] = ipa_path if ipa_path
if ENV["BUILD_NUMBER"]
options[:build_number] = ENV["BUILD_NUMBER"]
options[:skip_binary_upload] = true
end
options[:app_version] = ENV["VERSION"] if ENV["VERSION"]
deliver(**options)
end
end
+5
View File
@@ -0,0 +1,5 @@
git_url("https://gitlab.com/soapbox-pub/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["pub.ditto.app"])
team_id("GZLTTH5DLM")
@@ -0,0 +1 @@
Placeholder. CI overwrites this file with the latest CHANGELOG.md section per release.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.14.0",
"version": "2.14.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.14.0",
"version": "2.14.3",
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/app": "^8.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.14.2",
"version": "2.14.3",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## [2.14.3] - 2026-05-11
### Changed
- Behind-the-scenes maintenance release. No user-facing changes.
## [2.14.2] - 2026-05-11
### Added
+6 -2
View File
@@ -13,10 +13,14 @@ import { DittoConfigSchema } from "./src/lib/schemas";
/**
* Load and validate the build-time ditto.json configuration file.
* Returns the parsed config object, or `undefined` if the file doesn't exist.
* Set the CONFIG_FILE env var to override the default path ("./ditto.json").
* Set the DITTO_CONFIG_FILE env var to override the default path ("./ditto.json").
*
* Why DITTO_CONFIG_FILE and not CONFIG_FILE: GitLab Runner sets CONFIG_FILE in
* its job environment to point at its own TOML config (~/.gitlab-runner/config.toml),
* so a generic name silently breaks every CI build that runs on a self-hosted runner.
*/
function loadDittoConfig(): object | undefined {
const configPath = path.resolve(process.env.CONFIG_FILE ?? "./ditto.json");
const configPath = path.resolve(process.env.DITTO_CONFIG_FILE ?? "./ditto.json");
let raw: string;
try {