| name | ci-cd-publishing |
| description | Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation. |
CI/CD Pipeline and Publishing
Ditto uses GitLab CI (.gitlab-ci.yml) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target.
Pipeline Overview
| Stage | Runs on | Job |
|---|
test | every commit (not tags) | npm run test |
deploy | default branch only | deploy-nsite (Vite build → nsyte) |
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
Releases are triggered by pushing a version tag:
npm run release
This creates a tag in the format v2026.03.14+abc1234 (date + short commit hash) and pushes it to GitLab, which triggers the build-apk, release, publish-zapstore, and publish-google-play jobs.
For the full versioning / changelog / native-build workflow, load the release skill.
Zapstore Publishing
The publish-zapstore CI job uploads signed APKs to Zapstore using the zsp CLI and NIP-46 bunker signing via Amber.
Configuration files:
zapstore.yaml — app metadata for Zapstore (name, tags, icon, supported NIPs)
.gitlab-ci.yml — the publish-zapstore job definition
GitLab CI/CD variables (Settings → CI/CD → Variables):
| Variable | Description | Protected | Masked | Raw |
|---|
ZAPSTORE_BUNKER_URL | NIP-46 bunker URL (bunker://<pubkey>?relay=...). No secret param needed after initial auth. | Yes | No | Yes |
ZAPSTORE_CLIENT_KEY | Hex private key used as the NIP-46 client identity for bunker communication | Yes | Yes | Yes |
ANDROID_KEYSTORE_BASE64 | Base64-encoded Android signing keystore | Yes | Yes | Yes |
KEYSTORE_PASSWORD | Android keystore password | Yes | Yes | Yes |
KEY_PASSWORD | Android key password | Yes | Yes | Yes |
How NIP-46 bunker auth works in CI
NIP-46 bunker signing requires two keys: the user's key (held by Amber) and a client key (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval.
The publish-zapstore job restores the client key from ZAPSTORE_CLIENT_KEY into ~/.config/zsp/bunker-keys/<bunker-pubkey>.key before running zsp, so the bunker recognizes the CI runner as an already-authorized client.
Initial setup (one-time)
Run the NIP-46 client-initiated auth script:
node scripts/nip46-auth.mjs
This generates a nostrconnect:// URI. Import/paste it into Amber and approve the connection. The script outputs the bunker:// URI and client key hex, and writes the client key to ~/.config/zsp/bunker-keys/. Update the GitLab CI/CD variables with the printed values.
Options:
--relay <url> — relay for NIP-46 communication (default: wss://relay.ditto.pub)
--name <name> — app name shown to the signer (default: Ditto)
--timeout <sec> — how long to wait for approval (default: 300)
After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables.
nsite Publishing
The deploy-nsite CI job deploys the Vite build to nsite on every push to the default branch using nsyte. The job uploads dist/ to Blossom servers and publishes site manifest events to Nostr relays.
nsyte uses a NIP-46 bunker credential called nbunksec — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via --sec.
GitLab CI/CD variables:
| Variable | Description | Protected | Masked | Raw |
|---|
NSITE_NBUNKSEC | nbunksec credential from nsyte ci. Must start with nbunksec1. | Yes | Yes | Yes |
Initial setup (one-time)
- Install nsyte locally:
curl -fsSL https://nsyte.run/get/install.sh | bash
- Generate the CI credential:
nsyte ci
This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an nbunksec1... string. The credential is shown only once.
- Add the
nbunksec1... value as NSITE_NBUNKSEC in GitLab CI/CD settings. Mark it as Protected and Masked.
Configured relays and servers
Relays the deploy job publishes to:
wss://relay.ditto.pub
wss://relay.nsite.lol
wss://relay.dreamith.to
wss://relay.primal.net
Blossom servers:
https://blossom.primal.net
https://blossom.ditto.pub
https://blossom.dreamith.to
The --use-fallback-relays and --use-fallback-servers flags include nsyte's built-in defaults for broader coverage. The --fallback "/index.html" flag enables SPA client-side routing.
Credential rotation
To rotate the nsite credential:
- Revoke the old bunker connection in your signer app.
- Run
nsyte ci again to generate a new nbunksec1... string.
- Update the
NSITE_NBUNKSEC variable in GitLab CI/CD settings.
Google Play Publishing
The publish-google-play CI job uploads Android AABs to Google Play using fastlane supply. It runs after a successful AAB build and uploads directly to the production track.
GitLab CI/CD variables:
| Variable | Description | Protected | Masked | Raw |
|---|
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON | Base64-encoded contents of the Google Play API service account key JSON. The CI job decodes with base64 -d before passing to fastlane supply. | Yes | Yes | No |
Initial setup (one-time)
-
Create or reuse a project in Google Cloud Console.
-
Enable the Google Play Developer API for that project.
-
In Google Cloud Console, go to Service Accounts, create a service account, and download a JSON key file for it.
-
In Google Play Console, go to Users & Permissions, click Invite new users, enter the service account email, and grant it permission to manage releases for pub.ditto.app.
-
Base64-encode the key file:
base64 -w0 service-account.json
base64 -i service-account.json | tr -d '\n'
-
Add the base64-encoded value as GOOGLE_PLAY_SERVICE_ACCOUNT_JSON in GitLab CI/CD settings. Mark it as Protected and Masked. Do not paste the raw JSON — the CI script expects base64 and will fail to decode a raw value.
Key points
- The job uploads the signed AAB (not APK) — Google Play requires App Bundles.
- Uploads go directly to the production track. Google's review process still applies before the update reaches users.
- Metadata, screenshots, and store-listing description are managed in the Play Console (the job uses
--skip_upload_metadata, --skip_upload_images, --skip_upload_screenshots).
- Changelogs ("What's new in this version") are uploaded from
android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt, generated at CI time from the release summary paragraph in CHANGELOG.md. See "Release notes pipeline" below.
- 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, tags: [macos]) also runs on the self-hosted Mac runner. Consumes the IPA artifact via needs: [build-ipa] and the release-notes artifact via needs: [release-notes]. Decodes the API key, copies the release-notes summary into ios/fastlane/metadata/en-US/release_notes.txt, and runs fastlane submit_release which calls deliver to upload metadata + push the prebuilt IPA + auto-submit for App Store review. macOS is required even though the IPA is already signed: fastlane deliver shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode. A Linux container ran into No such file or directory @ dir_chdir0 from JavaTransporterExecutor#execute because Helper.itms_path resolved to a missing Xcode path.
The Mac runner is therefore used for both iOS jobs. 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 with the release summary paragraph from CHANGELOG.md per release
.gitlab-ci.yml — build-ipa and publish-app-store both run on the Mac runner (tags: [macos])
Code signing storage: a private GitLab repo soapbox-pub/certificates holds encrypted distribution certs and provisioning profiles, managed by fastlane 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 (.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: copied from the release-notes job's artifact artifacts/release-notes-summary.txt (the leading plaintext paragraph of the version's CHANGELOG.md section) into ios/fastlane/metadata/en-US/release_notes.txt, uploaded by deliver as the App Store "What's New in This Version" text. See "Release notes pipeline" below.
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)
-
Provision the Mac runner. See the mac-runner skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and gitlab-runner registration.
-
Create the App Store Connect API key. Log in to App Store Connect → 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:
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"
base64 -i AuthKey_<KEY_ID>.p8 | tr -d '\n'
-
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).
-
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.
-
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 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):
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:
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"
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:
cd ~/Projects/ditto/ios
fastlane match nuke distribution
fastlane match appstore
CI's next tag run picks up the new files automatically (match(... readonly: true)).
Disaster recovery (Mac dies / new developer joins)
git clone https://gitlab.com/soapbox-pub/ditto.git
cd ditto/ios
fastlane match appstore --readonly
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:
- App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key
- Download the new
.p8, note the new key ID
- Update
APP_STORE_CONNECT_API_KEY_ID and APP_STORE_CONNECT_API_KEY_P8_BASE64 in GitLab variables
- (Issuer ID stays the same — it's per-team, not per-key)
- 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 (also Mac) 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") come from the release-notes summary paragraph (see "Release notes pipeline" below).
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 does no code signing, but it still needs macOS: fastlane deliver shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode.
Release notes pipeline
Release notes for all three storefronts (App Store, Google Play, GitLab Release page) and the in-app version-update toast are derived from a single source: CHANGELOG.md.
The release-notes job (stage build, default node:22 image, runs only on v* tags) calls scripts/extract-release-notes.mjs twice and publishes two artifacts:
artifacts/release-notes.md — the full section for this version (summary paragraph + ### Added / ### Changed / etc. lists). Used as the GitLab Release description.
artifacts/release-notes-summary.txt — only the leading plaintext paragraph (max 500 chars by convention). Used as the App Store / Play Store "What's new" text. Falls back to Ditto vX.Y.Z if the section has no summary paragraph.
Downstream consumers all pull from the release-notes job via needs::
| Consumer | Job | Artifact used |
|---|
| GitLab Release description | release | release-notes.md |
| App Store "What's New" | publish-app-store | release-notes-summary.txt → copied to ios/fastlane/metadata/en-US/release_notes.txt → uploaded by deliver |
| Play Store "What's new" | publish-google-play | release-notes-summary.txt → copied to android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt → uploaded by supply |
| In-app toast | src/components/VersionCheck.tsx (runtime) | Re-parses public/CHANGELOG.md via parseChangelog() and reads entry.summary (with a fallback to the legacy first-bullet behavior) |
The summary format is documented in the release skill — a single plaintext paragraph immediately under the ## [X.Y.Z] - YYYY-MM-DD heading, before any ### Category. The script enforces nothing on the parser side; CI emits a warning when the summary exceeds 500 chars but does not fail the build.
To preview locally what each storefront will receive:
node scripts/extract-release-notes.mjs vX.Y.Z
node scripts/extract-release-notes.mjs vX.Y.Z --summary