diff --git a/.agents/skills/journal-build/SKILL.md b/.agents/skills/journal-build/SKILL.md
index cfdf615d..48d8012c 100644
--- a/.agents/skills/journal-build/SKILL.md
+++ b/.agents/skills/journal-build/SKILL.md
@@ -14,4 +14,6 @@ Run `bash .agents/skills/journal-build/scripts/build_journal_apple.sh --help` fr
Only `status: built` confirms an App. `preflight-passed` and `link-only` are not runtime acceptance. Preserve `cross_abi_validated=false` for host-restamped objects and hand off the manifest with the app. Installation and phone/graph operations require their own scope.
+For iPhone-only archive/export/upload, read [the release runbook](../../../docs/ios-release.md). Fastlane reuses current verified native inputs with explicit experimental opt-in; it does not establish a verified cross-release toolchain. Upload, Apple processing, encryption compliance and explicit internal-group availability are separate stages. Never silently retry an uncertain upload.
+
For build-tool changes run `PYTHONDONTWRITEBYTECODE=1 python3 .agents/skills/journal-build/scripts/test_journal_build.py`. Use `scripts/test_apple_app_icon.py` when packaging changes and the owner/environment permit actool; use `sh test/test_native_static_gmp.sh .agents/skills/journal-build/scripts/native_static_link_flags.sh` for static-link helper changes. A layout-only move needs path/import and Dune resource checks, not a new production build or GUI run.
diff --git a/.agents/skills/journal-build/scripts/journal_build.py b/.agents/skills/journal-build/scripts/journal_build.py
index 529eb990..2f9fd20c 100644
--- a/.agents/skills/journal-build/scripts/journal_build.py
+++ b/.agents/skills/journal-build/scripts/journal_build.py
@@ -1031,6 +1031,9 @@ def assemble(self, product, app):
if not (resource / "Assets.car").is_file():
raise RuntimeError("icon Assets.car missing")
packaged = plistlib.loads(plist.read_bytes())
+ if not mac and (packaged.get("UIDeviceFamily") != [1]
+ or packaged.get("ITSAppUsesNonExemptEncryption") is not False):
+ raise RuntimeError("Journal iOS packaging requires iPhone-only family and the reviewed encryption declaration")
if not any(key in packaged for key in ("CFBundleIconName", "CFBundleIcons")):
raise RuntimeError("packaged icon declaration missing")
self.phase = "sign"
diff --git a/.gitignore b/.gitignore
index 1d05c220..8f2c9165 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,14 @@ __pycache__/
/docs/test-reports/
/.ci-deps/
/.ci-reports/
+
+# Local release dependencies and outputs; signing material stays outside Git.
+/.bundle/
+/vendor/bundle/
+/fastlane/report.xml
+/fastlane/README.md
+*.ipa
+*.xcarchive/
+*.mobileprovision
+*.p12
+*.p8
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 00000000..035abf43
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,2 @@
+source "https://rubygems.org"
+gem "fastlane", "2.241.1"
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 00000000..51d9f9a2
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,268 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ CFPropertyList (3.0.8)
+ abbrev (0.1.2)
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
+ artifactory (3.0.17)
+ atomos (0.1.3)
+ aws-eventstream (1.4.0)
+ aws-partitions (1.1296.0)
+ aws-sdk-core (3.258.0)
+ aws-eventstream (~> 1, >= 1.3.0)
+ aws-partitions (~> 1, >= 1.992.0)
+ aws-sigv4 (~> 1.9)
+ base64
+ bigdecimal
+ jmespath (~> 1, >= 1.6.1)
+ logger
+ rexml (~> 3.4, >= 3.4.2)
+ aws-sdk-kms (1.133.0)
+ aws-sdk-core (~> 3, >= 3.258.0)
+ aws-sigv4 (~> 1.5)
+ aws-sdk-s3 (1.234.0)
+ aws-sdk-core (~> 3, >= 3.258.0)
+ aws-sdk-kms (~> 1)
+ aws-sigv4 (~> 1.5)
+ aws-sigv4 (1.12.1)
+ aws-eventstream (~> 1, >= 1.0.2)
+ babosa (1.0.4)
+ base64 (0.3.0)
+ benchmark (0.5.0)
+ bigdecimal (4.1.3)
+ cgi (0.5.2)
+ claide (1.1.0)
+ colored (1.2)
+ colored2 (3.1.2)
+ commander (4.6.0)
+ highline (~> 2.0.0)
+ csv (3.3.6)
+ declarative (0.0.20)
+ digest-crc (0.7.0)
+ rake (>= 12.0.0, < 14.0.0)
+ domain_name (0.7.20261007)
+ dotenv (2.8.1)
+ emoji_regex (3.2.3)
+ erb (6.0.7)
+ excon (1.7.3)
+ logger
+ faraday (2.15.0)
+ faraday-net_http (>= 2.0, < 3.6)
+ json
+ logger
+ faraday-cookie_jar (0.0.8)
+ faraday (>= 0.8.0)
+ http-cookie (>= 1.0.0)
+ faraday-follow_redirects (0.5.0)
+ faraday (>= 1, < 3)
+ faraday-multipart (1.2.0)
+ multipart-post (~> 2.0)
+ faraday-net_http (3.5.0)
+ net-http (~> 0.5)
+ faraday-retry (2.4.0)
+ faraday (~> 2.0)
+ fastimage (2.4.1)
+ fastlane (2.241.1)
+ CFPropertyList (>= 2.3, < 5.0.0)
+ abbrev (~> 0.1)
+ addressable (>= 2.9.0, < 3.0.0)
+ artifactory (~> 3.0)
+ aws-sdk-s3 (~> 1.197)
+ babosa (>= 1.0.3, < 2.0.0)
+ base64 (~> 0.2)
+ benchmark (>= 0.1.0)
+ bundler (>= 2.4.0, < 5.0.0)
+ cgi (~> 0.4)
+ colored (~> 1.2)
+ commander (~> 4.6)
+ csv (~> 3.3)
+ dotenv (>= 2.1.1, < 3.0.0)
+ emoji_regex (>= 0.1, < 4.0)
+ erb (>= 4.0, < 7.0)
+ excon (>= 1.0.0, < 2.0.0)
+ faraday (~> 2.7)
+ faraday-cookie_jar (~> 0.0.8)
+ faraday-follow_redirects (~> 0.3)
+ faraday-multipart (~> 1.0)
+ faraday-retry (~> 2.0)
+ fastimage (>= 2.1.0, < 3.0.0)
+ fastlane-sirp (>= 1.1.0)
+ gh_inspector (>= 1.1.2, < 2.0.0)
+ google-apis-androidpublisher_v3 (~> 0.99)
+ google-apis-playcustomapp_v1 (~> 0.1)
+ google-cloud-env (>= 1.6.0, < 2.5.0)
+ google-cloud-storage (~> 1.31)
+ highline (~> 2.0)
+ irb (>= 1.8)
+ json (< 3.0.0)
+ jwt (>= 2.10.3, < 4)
+ logger (>= 1.6, < 2.0)
+ mini_magick (>= 4.9.4, < 5.0.0)
+ multi_json (~> 1.12)
+ multipart-post (>= 2.0.0, < 3.0.0)
+ mutex_m (~> 0.3)
+ naturally (~> 2.2)
+ nkf (~> 0.2)
+ optparse (>= 0.1.1, < 1.0.0)
+ ostruct (>= 0.1.0)
+ plist (>= 3.1.0, < 4.0.0)
+ rubyzip (>= 3.4.0, < 4.0.0)
+ security (~> 0.3)
+ simctl (~> 1.6.3)
+ terminal-notifier (>= 3.1.0, < 4.0.0)
+ terminal-table (~> 4)
+ tty-screen (>= 0.6.3, < 1.0.0)
+ tty-spinner (>= 0.8.0, < 1.0.0)
+ word_wrap (~> 1.0.0)
+ xcodeproj (>= 1.13.0, < 2.0.0)
+ xcpretty (~> 0.4.1)
+ xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
+ fastlane-sirp (1.1.0)
+ gh_inspector (1.1.3)
+ google-apis-androidpublisher_v3 (0.109.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-core (1.3.2)
+ addressable (~> 2.9)
+ faraday (~> 2.13)
+ faraday-follow_redirects (~> 0.3)
+ googleauth (~> 1.14)
+ mini_mime (~> 1.1)
+ multi_json (~> 1.11)
+ representable (~> 3.0)
+ retriable (>= 3.1, < 5.0)
+ google-apis-iamcredentials_v1 (0.28.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-playcustomapp_v1 (0.18.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-storage_v1 (0.69.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-cloud-core (1.9.0)
+ google-cloud-env (>= 1.0, < 3.a)
+ google-cloud-errors (~> 1.0)
+ google-cloud-env (2.4.1)
+ base64 (~> 0.2)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-errors (1.7.0)
+ google-cloud-storage (1.62.1)
+ addressable (~> 2.8)
+ digest-crc (~> 0.4)
+ google-apis-core (>= 0.18, < 2)
+ google-apis-iamcredentials_v1 (~> 0.18)
+ google-apis-storage_v1 (>= 0.42)
+ google-cloud-core (~> 1.6)
+ googleauth (~> 1.9)
+ mini_mime (~> 1.0)
+ google-logging-utils (0.2.1)
+ googleauth (1.17.5)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-env (~> 2.2)
+ google-logging-utils (~> 0.1)
+ jwt (>= 1.4, < 4.0)
+ os (>= 0.9, < 2.0)
+ pstore (~> 0.1)
+ signet (>= 0.16, < 2.a)
+ highline (2.0.3)
+ http-cookie (1.1.6)
+ domain_name (~> 0.5)
+ io-console (0.9.4)
+ irb (1.18.0)
+ pp (>= 0.6.0)
+ prism (>= 1.3.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ jmespath (1.6.2)
+ json (2.21.2)
+ jwt (3.3.0)
+ base64
+ logger (1.7.0)
+ mini_magick (4.13.2)
+ mini_mime (1.1.5)
+ multi_json (1.21.2)
+ multipart-post (2.4.1)
+ mutex_m (0.3.0)
+ nanaimo (0.4.0)
+ naturally (2.3.0)
+ net-http (0.10.0)
+ net-protocol (>= 0.3.0)
+ uri (>= 0.11.1)
+ net-protocol (0.4.0)
+ timeout
+ nkf (0.3.0)
+ optparse (0.8.1)
+ os (1.1.4)
+ ostruct (0.6.3)
+ plist (3.7.2)
+ pp (0.6.4)
+ prettyprint
+ prettyprint (0.2.1)
+ prism (1.9.0)
+ pstore (0.2.1)
+ public_suffix (7.0.5)
+ rake (13.4.2)
+ rbs (4.2.0)
+ logger
+ prism (>= 1.6.0)
+ tsort
+ rdoc (8.1.0)
+ erb
+ prism (>= 1.6.0)
+ rbs (>= 4.0.0)
+ tsort
+ reline (0.7.0)
+ io-console (~> 0.5)
+ representable (3.3.1)
+ declarative (< 0.1.0)
+ trailblazer-option (>= 0.1.1, < 0.2.0)
+ uber (< 0.2.0)
+ retriable (4.2.0)
+ rexml (3.4.4)
+ rouge (3.28.0)
+ rubyzip (3.7.0)
+ security (0.3.0)
+ signet (0.22.1)
+ addressable (~> 2.8)
+ faraday (>= 0.17.5, < 3.a)
+ jwt (>= 1.5, < 4.0)
+ simctl (1.6.10)
+ CFPropertyList
+ naturally
+ terminal-notifier (3.1.0)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ timeout (0.6.1)
+ trailblazer-option (0.1.2)
+ tsort (0.2.0)
+ tty-cursor (0.7.1)
+ tty-screen (0.8.2)
+ tty-spinner (0.9.3)
+ tty-cursor (~> 0.7)
+ uber (0.1.0)
+ unicode-display_width (3.3.0)
+ unicode-emoji (~> 4.3)
+ unicode-emoji (4.3.0)
+ uri (1.1.2)
+ word_wrap (1.0.0)
+ xcodeproj (1.28.1)
+ CFPropertyList (>= 2.3.3, < 4.0)
+ atomos (~> 0.1.3)
+ base64
+ claide (>= 1.0.2, < 2.0)
+ colored2 (~> 3.1)
+ nanaimo (~> 0.4.0)
+ nkf
+ rexml (>= 3.3.6, < 4.0)
+ xcpretty (0.4.1)
+ rouge (~> 3.28.0)
+ xcpretty-travis-formatter (1.0.1)
+ xcpretty (~> 0.2, >= 0.0.7)
+
+PLATFORMS
+ arm64-darwin-24
+ ruby
+
+DEPENDENCIES
+ fastlane (= 2.241.1)
+
+BUNDLED WITH
+ 2.6.9
diff --git a/apple/Info.plist b/apple/Info.plist
index eea1fafe..189ad60f 100644
--- a/apple/Info.plist
+++ b/apple/Info.plist
@@ -20,6 +20,12 @@
26.0
MinimumOSVersion
26.0
+ UIDeviceFamily
+
+ 1
+
+ ITSAppUsesNonExemptEncryption
+
NSCameraUsageDescription
Take a photo to attach to a journal entry.
NSPhotoLibraryAddUsageDescription
diff --git a/docs/ios-release.md b/docs/ios-release.md
new file mode 100644
index 00000000..f9278c36
--- /dev/null
+++ b/docs/ios-release.md
@@ -0,0 +1,153 @@
+# Journal iOS release
+
+Journal is a native iPhone target: `TARGETED_DEVICE_FAMILY=1`, final
+`UIDeviceFamily=[1]`. Keep the existing iPhone orientations. This does not prohibit
+iPad compatibility-mode installation.
+
+## Supported path and limits
+
+Use the maintained [Apple build entry](../.agents/skills/journal-build/SKILL.md)
+to produce a current, hash-verified device build receipt. The Fastlane preparation
+lane reuses its native object and target C object, and builds Swift as Release.
+At present this adapter supports only **explicit experimental host-restamp**.
+It preserves `JournalCrossABIValidated=false`, the source SHA and actual native
+configuration. An Xcode Release archive or Apple accepting an upload does not
+prove full iOS cross-compilation, ABI correctness, runtime acceptance or testability.
+There is no implicit production cross-release fallback.
+
+Use a clean checkout and a prepared, clean LUI checkout matching the receipt.
+Never edit a receipt to make stale inputs pass. New source revisions require new
+verified receipts; compatible caches may be reused through the build entry.
+Preparation fixes the receipt digest and native linker paths. Replacing a receipt
+or changing the prepared project's link inputs requires a new prepared project.
+Coordinate build/cache ownership with other tasks. No lane runs `clean`, installs
+dependencies, creates certificates/profiles, or installs on a phone.
+
+## Dependencies and separate lanes
+
+On a Mac with Xcode and Ruby 3.4, run `bundle install` using the checked-in lockfile
+(RubyGems). Keep Bundler's local dependencies outside tracked source. Set
+`FASTLANE_OPT_OUT_USAGE=1` and `FASTLANE_SKIP_UPDATE_CHECK=1` before the CLI.
+Do not put passwords, tokens or private keys in lane parameters or shell commands.
+
+The following variables denote **operator-selected external paths**, not defaults:
+`RELEASE_DIR` for a new release, `RELEASE_CACHE` for an exclusively owned reusable
+cache, `RELEASE_STATE` for durable attempt history, `DEVICE_RECEIPT` and
+`LUI_PACKAGE` for existing verified inputs. All generated outputs stay outside
+the repository. Preserve old packages and attempt records. Use an unused release
+directory; serialize access to each cache.
+
+```sh
+bundle exec fastlane ios prepare manifest:"$DEVICE_RECEIPT" lui:"$LUI_PACKAGE" \
+ prepared:"$RELEASE_DIR/prepared" version:"$VERSION" build:"$BUILD" experimental:true
+bundle exec fastlane ios archive prepared:"$RELEASE_DIR/prepared" \
+ archive:"$RELEASE_DIR/Journal.xcarchive" cache:"$RELEASE_CACHE"
+bundle exec fastlane ios export prepared:"$RELEASE_DIR/prepared" \
+ archive:"$RELEASE_DIR/Journal.xcarchive" output:"$RELEASE_DIR/export" \
+ profile:"$EXISTING_APPSTORE_PROFILE" identity:"$EXISTING_CERTIFICATE_SHA1"
+```
+
+Signing uses an existing local Apple Distribution identity and Journal App Store
+profile. Acquire any necessary signing access through your approved secure
+workflow; these lanes never change the signature repository, create identities,
+import keys, or alter Keychain permissions. A macOS signing permission prompt
+must be handled locally by its owner. Do not weaken access controls to suppress it.
+
+Export preserves version/build numbers, verifies dSYM UUIDs at archive time, and
+writes `export/verification.json`. Final IPA verification checks its SHA256,
+Bundle, Team, version/build, device family, encryption declaration, source SHA,
+experimental provenance, code signature, App Store profile, signed entitlements,
+certificate membership, arm64 architecture and iPhoneOS Mach-O platform. Source
+SHA must equal the current verified receipt, not merely have the right format.
+It does not certify full runtime ABI compatibility.
+
+## Upload and repeat-attempt rules
+
+Stable identity is `com.logseq.journal`, Developer Team `K378MFWK59`, ASC app
+`6824952205`, account `dev@logseq.com`. Do not replace the original Logseq app.
+`SOURCE_SHA` is the intended full source revision from the verified build receipt;
+the upload preflight requires an exact match in the IPA.
+
+Before **each attempt**, inspect that app's ASC Build Uploads and TestFlight
+records for the same version/build. Check for active uploads and confirm that
+automatic distribution cannot send the build to any non-target group. An empty
+build list alone does not resolve an interrupted transport or pending upload.
+If Apple has received the build, follow that build instead of uploading again.
+
+After the check, copy `verification.json` to an external `receipt-check.json` and
+add these operator-attested fields: `checked_utc` (current UTC ISO8601),
+`accepted_build_visible: false`, `active_upload: false`, and
+`non_target_automatic_distribution: false`. Keep all verification fields intact.
+The upload gate requires an exact package match and a check less than 15 minutes
+old. This file records a human ASC check; it is not an automated Apple API result.
+
+```sh
+bundle exec fastlane ios upload ipa:"$RELEASE_DIR/export/Journal.ipa" \
+ version:"$VERSION" build:"$BUILD" sha256:"$IPA_SHA256" source:"$SOURCE_SHA" experimental:true \
+ state:"$RELEASE_STATE" receipt_check:"$RELEASE_DIR/receipt-check.json"
+```
+
+Run locally in a terminal. Enter the existing App-specific password in the hidden
+prompt, never in chat, arguments, an env file, history or a saved credential store.
+The password is supplied only to this process and Apple's upload child, using an
+environment-variable reference in the Altool command. The IPA is copied and
+hash-checked before upload. Only the sanitized error text and process exit code
+are persisted. The uploader does not perform ordinary Apple login, 2FA, credential
+creation or credential-store fallback. It does not automatically retry.
+
+Use the **same durable state directory** for this app. A lock and per-build attempt
+records prevent a silent repeat, including a changed IPA with the same build.
+If an attempt failed or was interrupted, first resolve Apple receipt status and
+get authorization for another attempt. Only then pass `retry_upload:true` with a
+fresh exact-package check. A tool-reported successful upload is never retried by
+this entry. Do not delete locks/history, increment the build number, or change
+state directories merely to escape an uncertain result.
+
+## Completion and distribution
+
+Track these separately: tool upload result; Apple's delivery UUID/receipt;
+processing Complete/Invalid; Missing Compliance or cleared compliance; and the
+specific group's Ready to Test/Testing state. Exit 0 alone is not success:
+Altool can print `ERROR:` with exit 0. Full sanitized multiline/structured errors
+are retained alongside the exit code. Never infer a password or ABI cause from
+a generic failure. Preserve the report, inspect Apple, then decide the next step.
+
+Upload explicitly skips submission, waiting and tester distribution. After Apple
+processing and compliance are resolved, assign only the existing internal
+**Logseq Team** group (`3fcd1cd3-cafa-44e4-987d-47a752174c32`) in ASC under separately
+authorized account access. Check the group belongs to Journal and verify build
+availability there. No lane selects all groups, invites testers, enables external
+testing or publishes to the App Store. App-specific upload credentials do not
+provide group-management access.
+
+## Encryption declaration basis
+
+`apple/Info.plist` carries `ITSAppUsesNonExemptEncryption=false`, matching the
+team's original Logseq iOS declaration at
+[3d41a46](https://github.com/logseq/logseq/blob/3d41a46bcd40b07c60856f76f4ea1ad89aad1db7/ios/App/App/Info.plist#L53).
+The owner confirmed the same encryption implementation applies to Journal; this
+configuration carries forward the existing team declaration. It is not a general legal guarantee.
+Apple defines `false` as either no encryption **or only exempt encryption**,
+including linked third-party libraries; it does not mean no cryptography exists.
+See [Apple's key semantics](https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption).
+
+Graph/assets use AES-GCM via CryptoKit and RSA-OAEP via Security; Swift implements
+PBKDF2 using CryptoKit HMAC-SHA256. OCaml TLS/Mirage-crypto is also shipped.
+Algorithm-type questionnaire answers and documentation exemptions are distinct.
+Reassess the declaration when encryption functionality, dependencies or intended
+distribution changes, including applicable France requirements. Never set `false`
+solely to bypass Missing Compliance. A previously uploaded build missing this
+key can have its declaration completed in ASC without reuploading just for this key.
+
+## Offline verification
+
+```sh
+PYTHONDONTWRITEBYTECODE=1 python3 .agents/skills/journal-build/scripts/test_journal_build.py
+```
+
+Release tooling was checked using temporary, external synthetic fixtures and the
+actual pinned Pilot/Altool parser with its PTY execution replaced. Those fixtures
+are not shipped in this repository. No Apple network call, credential store,
+signing identity or graph is needed for that check. Keep credentials, profiles,
+private certificates/keys, recovery GPG material, graphs, raw upload logs,
+IPAs/archives/dSYMs and local attempt evidence outside Git.
diff --git a/fastlane/Appfile b/fastlane/Appfile
new file mode 100644
index 00000000..f6075ca7
--- /dev/null
+++ b/fastlane/Appfile
@@ -0,0 +1,3 @@
+app_identifier('com.logseq.journal')
+apple_id('dev@logseq.com')
+team_id('K378MFWK59')
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
new file mode 100644
index 00000000..4624bcea
--- /dev/null
+++ b/fastlane/Fastfile
@@ -0,0 +1,86 @@
+require_relative 'lib/journal_release'
+require 'shellwords'
+ENV['FASTLANE_OPT_OUT_USAGE']='1'
+ENV['FASTLANE_SKIP_UPDATE_CHECK']='1'
+default_platform(:ios)
+
+def required_path(options, name)
+ JournalRelease.external!(options.fetch(name).to_s)
+end
+
+def prepared_inputs(options)
+ JournalRelease.prepared_inputs!(required_path(options,:prepared))
+end
+
+platform :ios do
+ desc 'Prepare an unsigned iPhone project from an explicitly experimental device build receipt'
+ lane :prepare do |o|
+ JournalRelease.prepare!(root: JournalRelease::ROOT,manifest_path: File.expand_path(o.fetch(:manifest)),
+ lui: File.expand_path(o.fetch(:lui)),output: required_path(o,:prepared),
+ version: o.fetch(:version),build: o.fetch(:build),experimental: o[:experimental]==true || o[:experimental]=='true')
+ end
+
+ desc 'Archive Swift Release with existing native provenance; no signing, cleaning or dependency updates'
+ lane :archive do |o|
+ prepared,inputs=prepared_inputs(o)
+ archive=required_path(o,:archive); cache=required_path(o,:cache)
+ UI.user_error!('Archive already exists; preserve it') if File.exist?(archive)
+ build_app(project: File.join(prepared,'Journal.xcodeproj'),scheme: 'Journal',
+ configuration: 'Release',sdk: 'iphoneos',destination: 'generic/platform=iOS',
+ archive_path: archive,derived_data_path: File.join(cache,'DerivedData'),
+ buildlog_path: File.join(cache,'logs'),build_path: File.join(cache,'archives'),
+ clean: false,skip_codesigning: true,skip_package_ipa: true,skip_profile_detection: true,
+ skip_package_dependencies_resolution: true,disable_package_automatic_updates: true,
+ skip_package_repository_fetches: true,xcodebuild_formatter: '',
+ xcargs: "CODE_SIGNING_ALLOWED=NO -jobs 1 -packageCachePath #{Shellwords.escape(File.join(cache,'PackageCache'))} -clonedSourcePackagesDirPath #{Shellwords.escape(File.join(cache,'SourcePackages'))}")
+ prepared_inputs(o)
+ app=File.join(archive,'Products/Applications/JournalApp.app')
+ JournalRelease.validate_info!(JournalRelease.read_plist(File.join(app,'Info.plist')),version: inputs.fetch('version'),build: inputs.fetch('build'),source_sha: inputs.fetch('source_sha'),experimental: true)
+ executable=File.join(app,'JournalApp'); symbols=File.join(archive,'dSYMs/JournalApp.app.dSYM')
+ ids=->(path) { JournalRelease.run!('xcrun','dwarfdump','--uuid',path).scan(/UUID: ([0-9A-F-]+) \(([^)]+)\)/).sort }
+ UI.user_error!('Archive dSYM UUID mismatch') unless !ids.call(executable).empty? && ids.call(executable)==ids.call(symbols)
+ end
+
+ desc 'Export using an existing Journal App Store profile and identity; never create signing credentials'
+ lane :export do |o|
+ prepared,inputs=prepared_inputs(o)
+ archive=required_path(o,:archive); output=required_path(o,:output)
+ profile_path=File.expand_path(o.fetch(:profile)); identity=o.fetch(:identity).upcase
+ UI.user_error!('Expected a certificate SHA1, not a private key or password') unless identity.match?(/\A[0-9A-F]{40}\z/)
+ profile=Plist.parse_xml(JournalRelease.run!('/usr/bin/openssl','smime','-inform','der','-verify','-noverify','-in',profile_path),marshal: false)
+ JournalRelease.validate_profile!(profile,profile.fetch('Entitlements'),identity)
+ identities=JournalRelease.run!('security','find-identity','-v','-p','codesigning')
+ UI.user_error!('Existing Apple Distribution identity is unavailable') unless identities.lines.any? { |line| line.include?(identity) && line.include?('Apple Distribution:') }
+ UI.user_error!('Export directory already exists; preserve it') if File.exist?(output)
+ app=File.join(archive,'Products/Applications/JournalApp.app')
+ JournalRelease.validate_info!(JournalRelease.read_plist(File.join(app,'Info.plist')),version: inputs.fetch('version'),build: inputs.fetch('build'),source_sha: inputs.fetch('source_sha'),experimental: true)
+ build_app(project: File.join(prepared,'Journal.xcodeproj'),scheme: 'Journal',
+ skip_build_archive: true,archive_path: archive,output_directory: output,output_name: 'Journal.ipa',
+ buildlog_path: File.join(output,'logs'),export_method: 'app-store',skip_profile_detection: true,xcodebuild_formatter: '',
+ export_options: {method: 'app-store-connect',destination: 'export',signingStyle: 'manual',
+ teamID: JournalRelease::TEAM,signingCertificate: identity,
+ provisioningProfiles: {JournalRelease::BUNDLE=>profile.fetch('UUID')},
+ manageAppVersionAndBuildNumber: false,uploadSymbols: true})
+ prepared_inputs(o)
+ ipa=File.join(output,'Journal.ipa'); hash=Digest::SHA256.file(ipa).hexdigest
+ report=JournalRelease.verify_ipa!(ipa: ipa,version: inputs.fetch('version'),build: inputs.fetch('build'),sha256: hash,source_sha: inputs.fetch('source_sha'),experimental: true)
+ File.write(File.join(output,'verification.json'),JSON.pretty_generate(report)+"\n",mode:'wx',perm:0o600)
+ end
+
+ desc 'Verify a signed IPA offline without changing it or contacting Apple'
+ lane :verify do |o|
+ report=JournalRelease.verify_ipa!(ipa: File.expand_path(o.fetch(:ipa)),version: o.fetch(:version),
+ build: o.fetch(:build),sha256: o.fetch(:sha256),source_sha: o.fetch(:source),experimental: o[:experimental]==true || o[:experimental]=='true')
+ UI.message(JSON.pretty_generate(report))
+ end
+
+ desc 'One upload only, hidden process-local auth, fresh ASC check; no tester distribution'
+ lane :upload do |o|
+ ipa=File.expand_path(o.fetch(:ipa))
+ package=JournalRelease.verify_ipa!(ipa: ipa,version: o.fetch(:version),build: o.fetch(:build),
+ sha256: o.fetch(:sha256),source_sha: o.fetch(:source),experimental: o[:experimental]==true || o[:experimental]=='true')
+ report=JournalRelease.upload!(package: package,ipa: ipa,state_dir: required_path(o,:state),
+ proof: JSON.parse(File.read(o.fetch(:receipt_check))),retry_upload: o[:retry_upload]==true || o[:retry_upload]=='true')
+ UI.user_error!('Upload failed or remains uncertain; inspect sanitized attempt and Apple before retry') unless report['status']=='tool_reported_success'
+ end
+end
diff --git a/fastlane/lib/journal_project.rb b/fastlane/lib/journal_project.rb
new file mode 100644
index 00000000..f0fe9b0c
--- /dev/null
+++ b/fastlane/lib/journal_project.rb
@@ -0,0 +1,92 @@
+require 'xcodeproj'
+
+module JournalRelease
+ def self.prepared_inputs!(prepared)
+ external!(prepared)
+ inputs=JSON.parse(File.read(File.join(prepared,'release-inputs.json')))
+ raise Failure, 'Prepared receipt changed; prepare a new project' unless Digest::SHA256.file(inputs.fetch('manifest_path')).hexdigest==inputs.fetch('manifest_sha256')
+ manifest=verify_inputs!(**inputs.slice('root','manifest_path','lui','experimental').transform_keys(&:to_sym))
+ inputs['source_sha']=manifest.fetch('source_sha')
+ expected_flags=['$(inherited)',manifest.fetch('native_object').fetch('path'),manifest.fetch('production_link').fetch('target_c').fetch('path'),'-lsqlite3']
+ project=Xcodeproj::Project.open(File.join(prepared,'Journal.xcodeproj'))
+ target=project.targets.find { |t| t.name=='Journal' }
+ valid=target && target.build_configurations.all? do |config|
+ settings=config.build_settings
+ settings['TARGETED_DEVICE_FAMILY']=='1' && settings['PRODUCT_BUNDLE_IDENTIFIER']==BUNDLE && settings['OTHER_LDFLAGS']==expected_flags
+ end
+ raise Failure, 'Prepared project target or native link inputs changed' unless valid
+ info=Plist.parse_xml(File.read(File.join(prepared,'Info.plist')),marshal: false)
+ validate_info!(info.merge('CFBundleIdentifier'=>BUNDLE,'DTPlatformName'=>'iphoneos'),version: inputs.fetch('version'),build: inputs.fetch('build'),source_sha: inputs.fetch('source_sha'),experimental: inputs.fetch('experimental'))
+ [prepared,inputs]
+ end
+
+ def self.verify_inputs!(root:, manifest_path:, lui:, experimental: false)
+ manifest=JSON.parse(File.read(manifest_path))
+ raise Failure, 'Only explicitly experimental device receipts are supported' unless experimental && manifest.values_at('status','mode','cross_abi_validated','sdk_name','architecture')==['built','experimental-host-restamp',false,'iphoneos','arm64']
+ raise Failure, 'Source revision differs from receipt' unless run!('git','-C',root,'rev-parse','HEAD').strip==manifest.fetch('source_sha')
+ [root,lui].each { |p| raise Failure, 'Source/LUI checkout must be clean' unless run!('git','-C',p,'status','--porcelain','--untracked-files=all').empty? }
+ inputs=manifest.fetch('inputs')
+ raise Failure, 'LUI revision differs from receipt' unless run!('git','-C',lui,'rev-parse','HEAD').strip==inputs.fetch('lui_sha')
+ {'source'=>root,'lui'=>lui}.each do |kind,base|
+ inputs.fetch(kind).each do |relative,hash|
+ path=File.expand_path(relative,base)
+ raise Failure, 'Unsafe source receipt path' unless path.start_with?(File.expand_path(base)+'/')
+ raise Failure, "#{kind} input hash changed: #{relative}" unless Digest::SHA256.file(path).hexdigest==hash
+ end
+ end
+ [manifest.fetch('native_object'),manifest.fetch('production_link').fetch('target_c')].each do |object|
+ raise Failure, 'Native input hash changed' unless Digest::SHA256.file(object.fetch('path')).hexdigest==object.fetch('sha256')
+ end
+ manifest
+ end
+
+ def self.prepare!(root:, manifest_path:, lui:, output:, version:, build:, experimental: false)
+ root,lui,output=[root,lui,output].map { |p| File.expand_path(p) }
+ external!(output,root: root)
+ raise Failure, 'Prepared output already exists; preserve it' if File.exist?(output)
+ raise Failure, 'Invalid version/build' unless [version,build].all? { |s| s.to_s.match?(/\A\d+(?:\.\d+){0,2}\z/) }
+ manifest=verify_inputs!(root: root,manifest_path: manifest_path,lui: lui,experimental: experimental)
+ info=Plist.parse_xml(File.read(File.join(root,'apple/Info.plist')),marshal: false)
+ raise Failure, 'Source encryption declaration missing or changed' unless info['ITSAppUsesNonExemptEncryption']==false
+ info.merge!('CFBundleExecutable'=>'$(EXECUTABLE_NAME)','CFBundleIdentifier'=>'$(PRODUCT_BUNDLE_IDENTIFIER)',
+ 'CFBundleShortVersionString'=>version,'CFBundleVersion'=>build,'UIDeviceFamily'=>[1],
+ 'CFBundleURLTypes'=>[{'CFBundleURLName'=>'com.logseq.journal.auth','CFBundleURLSchemes'=>['logseqjournal']}],
+ 'JournalSourceRevision'=>manifest.fetch('source_sha'),'JournalBuildMode'=>'experimental-host-restamp',
+ 'JournalCrossABIValidated'=>false,'JournalOCamlRuntimeOrigin'=>'host-arm64-restamp',
+ 'JournalNativeConfiguration'=>manifest.fetch('inputs').fetch('flags').fetch('configuration'),'JournalArchiveConfiguration'=>'Release')
+ info.delete('LSMinimumSystemVersion')
+ FileUtils.mkdir_p(output)
+ File.write(File.join(output,'Info.plist'),Plist::Emit.dump(info))
+ project=Xcodeproj::Project.new(File.join(output,'Journal.xcodeproj'))
+ target=project.new_target(:application,'Journal',:ios,'26.0')
+ group=project.main_group.new_group('Journal Sources')
+ Dir[File.join(root,'swift','*.swift')].sort.reject { |p| File.basename(p)=='Package.swift' }.each do |path|
+ target.source_build_phase.add_file_reference(group.new_file(path))
+ end
+ target.resources_build_phase.add_file_reference(group.new_file(File.join(root,'assets/apple/Assets.xcassets')))
+ package=project.new(Xcodeproj::Project::Object::XCLocalSwiftPackageReference)
+ package.relative_path=lui; project.root_object.package_references << package
+ product=project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
+ product.package=package; product.product_name='LUIAppleBackendStatic'
+ target.package_product_dependencies << product
+ file=project.new(Xcodeproj::Project::Object::PBXBuildFile); file.product_ref=product
+ target.frameworks_build_phase.files << file
+ target.build_configurations.each do |config|
+ config.build_settings.merge!('PRODUCT_NAME'=>'JournalApp','PRODUCT_BUNDLE_IDENTIFIER'=>BUNDLE,
+ 'SDKROOT'=>'iphoneos','SUPPORTED_PLATFORMS'=>'iphoneos','ARCHS'=>'arm64',
+ 'IPHONEOS_DEPLOYMENT_TARGET'=>'26.0','TARGETED_DEVICE_FAMILY'=>'1','SWIFT_VERSION'=>'6.0',
+ 'INFOPLIST_FILE'=>File.join(output,'Info.plist'),'GENERATE_INFOPLIST_FILE'=>'NO',
+ 'MARKETING_VERSION'=>version,'CURRENT_PROJECT_VERSION'=>build,'CODE_SIGNING_ALLOWED'=>'NO',
+ 'CODE_SIGN_STYLE'=>'Manual','SKIP_INSTALL'=>'NO','DEBUG_INFORMATION_FORMAT'=>'dwarf-with-dsym',
+ 'SWIFT_OPTIMIZATION_LEVEL'=>(config.name=='Release' ? '-O' : '-Onone'),
+ 'ASSETCATALOG_COMPILER_APPICON_NAME'=>'AppIcon','SUPPORTS_MACCATALYST'=>'NO',
+ 'LD_GENERATE_MAP_FILE'=>'YES','COPY_PHASE_STRIP'=>'NO','STRIP_INSTALLED_PRODUCT'=>'NO',
+ 'OTHER_LDFLAGS'=>['$(inherited)',manifest.fetch('native_object').fetch('path'),manifest.fetch('production_link').fetch('target_c').fetch('path'),'-lsqlite3'])
+ end
+ project.save
+ scheme=Xcodeproj::XCScheme.new; scheme.add_build_target(target); scheme.set_launch_target(target)
+ scheme.archive_action.build_configuration='Release'; scheme.save_as(project.path,'Journal',true)
+ File.write(File.join(output,'release-inputs.json'),JSON.pretty_generate({root: root,manifest_path: File.expand_path(manifest_path),manifest_sha256: Digest::SHA256.file(manifest_path).hexdigest,lui: lui,experimental: true,version: version,build: build})+"\n")
+ project.path.to_s
+ end
+end
diff --git a/fastlane/lib/journal_release.rb b/fastlane/lib/journal_release.rb
new file mode 100644
index 00000000..88174bb4
--- /dev/null
+++ b/fastlane/lib/journal_release.rb
@@ -0,0 +1,170 @@
+require 'json'
+require 'digest'
+require 'time'
+require 'fileutils'
+require 'open3'
+require 'tmpdir'
+require 'plist'
+
+module JournalRelease
+ class Failure < StandardError; end
+ BUNDLE = 'com.logseq.journal'.freeze
+ TEAM = 'K378MFWK59'.freeze
+ APP_ID = '6824952205'.freeze
+ ACCOUNT = 'dev@logseq.com'.freeze
+ INTERNAL_GROUP = 'Logseq Team'.freeze
+ INTERNAL_GROUP_ID = '3fcd1cd3-cafa-44e4-987d-47a752174c32'.freeze
+ ROOT = File.expand_path('../..', __dir__)
+
+ def self.run!(*argv)
+ out, err, status = Open3.capture3(*argv.map(&:to_s))
+ raise Failure, "#{File.basename(argv.first.to_s)} exited #{status.exitstatus}: #{redact(out + err)}" unless status.success?
+ out
+ end
+
+ def self.external!(path, root: ROOT)
+ expanded = File.expand_path(path)
+ # Resolve existing ancestors too, so a symlink cannot put artifacts in Git.
+ ancestor = expanded
+ ancestor = File.dirname(ancestor) until File.exist?(ancestor)
+ resolved = File.realpath(ancestor) + expanded.delete_prefix(ancestor)
+ repository = File.realpath(root)
+ raise Failure, 'Keep generated hosts, signing material and reports outside the repository' if resolved == repository || resolved.start_with?(repository + '/')
+ expanded
+ end
+
+ def self.validate_info!(info, version:, build:, source_sha:, experimental: false)
+ expected = {'CFBundleIdentifier'=>BUNDLE, 'CFBundleShortVersionString'=>version,
+ 'CFBundleVersion'=>build, 'UIDeviceFamily'=>[1], 'ITSAppUsesNonExemptEncryption'=>false,
+ 'DTPlatformName'=>'iphoneos'}
+ expected.each { |key,value| raise Failure, "Unexpected #{key}" unless info[key] == value }
+ raise Failure, 'Source revision differs from the expected release' unless source_sha.to_s.match?(/\A[0-9a-f]{40}\z/) && info['JournalSourceRevision']==source_sha
+ raise Failure, 'Experimental host-restamp requires explicit opt-in; no verified cross-release route exists here' unless experimental && info['JournalBuildMode']=='experimental-host-restamp' && info['JournalCrossABIValidated']==false
+ true
+ end
+
+ def self.validate_profile!(profile, entitlements, certificate_sha1)
+ grants = profile.fetch('Entitlements')
+ identity = {'application-identifier'=>"#{TEAM}.#{BUNDLE}",
+ 'com.apple.developer.team-identifier'=>TEAM, 'get-task-allow'=>false, 'beta-reports-active'=>true}
+ valid = profile['TeamIdentifier']==[TEAM] && profile['ExpirationDate'].to_time > Time.now &&
+ !profile.key?('ProvisionedDevices') && !profile['ProvisionsAllDevices'] &&
+ identity.all? { |key,value| grants[key]==value && entitlements[key]==value }
+ certs = profile.fetch('DeveloperCertificates').map { |cert| Digest::SHA1.hexdigest(cert.respond_to?(:string) ? cert.string : cert).upcase }
+ valid &&= certs.include?(certificate_sha1.upcase)
+ allowed = lambda do |request,grant|
+ case request
+ when Array then grant.is_a?(Array) && request.all? { |v| grant.any? { |g| allowed.call(v,g) } }
+ when Hash then grant.is_a?(Hash) && request.all? { |k,v| allowed.call(v,grant[k]) }
+ when String then grant.is_a?(String) && File.fnmatch?(grant,request)
+ else request == grant
+ end
+ end
+ valid &&= entitlements.all? { |key,value| grants.key?(key) && allowed.call(value,grants[key]) }
+ raise Failure, 'Journal App Store profile, identity or signed entitlements do not match' unless valid
+ true
+ end
+
+ def self.redact(value, secrets: [])
+ text = value.to_s.encode('UTF-8',invalid: :replace,undef: :replace)
+ secrets.compact.reject(&:empty?).sort_by { |s| -s.length }.each { |s| text=text.gsub(s,'[REDACTED]') }
+ sensitive = '(?:password|passwd|pwd|secret|token|authorization|cookie|session|api[_-]?key|credential|jwt|bearer|private[_-]?key)'
+ text.gsub!(/-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----/m,'[REDACTED PRIVATE KEY]')
+ text.gsub!(/\b[a-z]{4}(?:-[a-z]{4}){3}\b/i,'[REDACTED APP PASSWORD]')
+ text.gsub!(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/,'[REDACTED JWT]')
+ text.gsub!(%r{(https?://)[^/\s:@]+:[^/\s@]+@},'\\1[REDACTED]@')
+ scrub = lambda do |node|
+ case node
+ when Hash then node.to_h { |k,v| [k,k.match?(/#{sensitive}/i) ? '[REDACTED]' : scrub.call(v)] }
+ when Array then node.map { |v| scrub.call(v) }
+ else node
+ end
+ end
+ offset=0
+ while (first=text.index(/[\[{]/,offset))
+ depth=0; quoted=false; escaped=false; last=nil
+ (first...text.length).each do |index|
+ char=text[index]
+ if quoted
+ if escaped then escaped=false
+ elsif char=='\\' then escaped=true
+ elsif char=='"' then quoted=false
+ end
+ elsif char=='"' then quoted=true
+ elsif '[{'.include?(char) then depth+=1
+ elsif ']}'.include?(char)
+ depth-=1
+ if depth.zero? then last=index; break; end
+ end
+ end
+ break unless last
+ begin
+ replacement=JSON.generate(scrub.call(JSON.parse(text[first..last])))
+ text[first..last]=replacement; offset=first+replacement.length
+ rescue JSON::ParserError
+ offset=first+1
+ end
+ end
+ text.gsub!(/(\s*[^<]*#{sensitive}[^<]*<\/key>\s*<(?:string|data)>).*?(<\/(?:string|data)>)/im,'\\1[REDACTED]\\2')
+ text.gsub!(/(<([\w:-]*#{sensitive}[\w:-]*)\b[^>]*>).*?(<\/\2>)/im,'\\1[REDACTED]\\3')
+ text.gsub!(/("[^"\n]*#{sensitive}[^"\n]*"\s*:\s*)"(?:\\.|[^"\\])*"/i,'\\1"[REDACTED]"')
+ text.gsub!(/(^[^\n]*\b(?:#{sensitive})[\w-]*\s*[:=]\s*)[^\n]*/i,'\\1[REDACTED]')
+ text.gsub!(/((?:--password|-p)\s+)(?:'[^']*'|"[^"]*"|\S+)/i,'\\1[REDACTED]')
+ text
+ end
+
+ def self.transport_success?(exit_code, output)
+ exit_code==0 && output.include?('UPLOAD SUCCEEDED with no errors') && !output.match?(/ERROR:|"errors"\s*:\s*\[\s*\{|STATE_ERROR\./)
+ end
+
+ def self.upload_options(ipa, version:, build:)
+ {username: ACCOUNT, apple_id: APP_ID, app_identifier: BUNDLE, app_platform: 'ios',
+ ipa: ipa, app_version: version, build_number: build,
+ skip_waiting_for_build_processing: true, skip_submission: true, distribute_external: false}
+ end
+
+ def self.with_snapshot(ipa,sha256)
+ Dir.mktmpdir('journal-upload-') do |directory|
+ snapshot=File.join(directory,'Journal.ipa')
+ FileUtils.copy_file(ipa,snapshot)
+ raise Failure, 'IPA changed after verification' unless Digest::SHA256.file(snapshot).hexdigest==sha256
+ yield snapshot
+ end
+ end
+
+ # Local attempt evidence is separate from Apple receipt/processing. A crash
+ # leaves a pending record that requires an explicit fresh ASC check and retry.
+ class Ledger
+ def initialize(directory, package)
+ @directory, @package = directory, package
+ FileUtils.mkdir_p(directory,mode: 0o700)
+ end
+
+ def attempt!(proof, retry_upload: false)
+ File.open(File.join(@directory,'upload.lock'),File::RDWR|File::CREAT,0o600) do |lock|
+ raise Failure, 'Another upload holds the lock' unless lock.flock(File::LOCK_EX|File::LOCK_NB)
+ key = Digest::SHA256.hexdigest(@package.values_at('app_id','version','build').join('/'))
+ prior = Dir[File.join(@directory,"#{key}-*.json")].sort.map { |p| JSON.parse(File.read(p)) }
+ raise Failure, 'Existing attempt requires a fresh ASC check and explicit retry_upload:true' if !prior.empty? && !retry_upload
+ raise Failure, 'Apple receipt must be resolved; successful upload cannot be retried' if prior.any? { |r| r['status']=='tool_reported_success' }
+ age = Time.now.utc-Time.iso8601(proof.fetch('checked_utc'))
+ valid = @package.all? { |k,v| proof[k]==v } && age.between?(0,900) && proof['accepted_build_visible']==false && proof['active_upload']==false && proof['non_target_automatic_distribution']==false
+ raise Failure, 'Fresh exact-package ASC receipt check required (within 15 minutes)' unless valid
+ path=File.join(@directory,"#{key}-#{format('%04d',prior.length+1)}.json")
+ record=@package.merge('started_utc'=>Time.now.utc.iso8601,'status'=>'failed_or_uncertain',
+ 'processing_state'=>'not_checked','distribution_changed'=>false)
+ File.write(path,JSON.pretty_generate(record)+"\n",mode:'wx',perm:0o600)
+ begin
+ record.merge!(yield)
+ ensure
+ record['finished_utc']=Time.now.utc.iso8601
+ File.write(path,JSON.pretty_generate(record)+"\n",perm:0o600)
+ end
+ record
+ end
+ end
+ end
+end
+require_relative 'journal_project'
+require_relative 'journal_verify'
+require_relative 'journal_upload'
diff --git a/fastlane/lib/journal_upload.rb b/fastlane/lib/journal_upload.rb
new file mode 100644
index 00000000..771fa969
--- /dev/null
+++ b/fastlane/lib/journal_upload.rb
@@ -0,0 +1,81 @@
+module JournalRelease
+ class << self
+ attr_accessor :transport
+ end
+
+ def self.install_transport_hooks!
+ return if @hooks_installed
+ require 'pilot'
+ FastlaneCore::FastlanePty.singleton_class.prepend(Module.new do
+ def spawn(command,&block)
+ evidence=JournalRelease.transport ||= {}
+ evidence[:started_utc]=Time.now.utc.iso8601
+ result=super(command,&block)
+ evidence[:exit_code]=result
+ result
+ ensure
+ evidence[:finished_utc]=Time.now.utc.iso8601
+ end
+ end)
+ FastlaneCore::AltoolTransporterExecutor.prepend(Module.new do
+ def execute(*args,&block)
+ super
+ ensure
+ text=(defined?(@all_lines) && @all_lines ? @all_lines.join : '')
+ errors=(defined?(@errors) && @errors ? @errors.join("\n") : '')
+ JournalRelease.transport ||= {}
+ JournalRelease.transport[:output]=JournalRelease.redact(text,secrets: [ENV['JOURNAL_UPLOAD_PASSWORD']])
+ JournalRelease.transport[:errors]=JournalRelease.redact(errors,secrets: [ENV['JOURNAL_UPLOAD_PASSWORD']])
+ end
+ end)
+ FastlaneCore::ItunesTransporter.prepend(Module.new do
+ def handle_two_step_failure(*)
+ raise JournalRelease::Failure, 'Apple upload authentication failed; no credential-store fallback or automatic retry'
+ end
+ end)
+ @hooks_installed=true
+ end
+
+ def self.upload!(package:, ipa:, state_dir:, proof:, retry_upload: false)
+ require 'io/console'
+ raise Failure, 'Run upload locally with a hidden terminal prompt' unless $stdin.tty? && $stdout.tty? && IO.console
+ external!(state_dir)
+ with_snapshot(ipa,package.fetch('ipa_sha256')) do |snapshot|
+ Ledger.new(state_dir,package).attempt!(proof,retry_upload: retry_upload) do
+ puts "Upload #{BUNDLE} #{package.fetch('version')} (#{package.fetch('build')}) to Apple app #{APP_ID}; no group changes."
+ secret=IO.console.getpass("#{ACCOUNT} existing App-specific password (hidden): ")
+ raise Failure, 'App-specific password format is invalid; no upload started' unless secret.match?(/\A[a-z]{4}(?:-[a-z]{4}){3}\z/)
+ saved=ENV.to_h
+ out,err=STDOUT.dup,STDERR.dup
+ result={'status'=>'failed_or_uncertain','stage'=>'pilot_configuration'}
+ begin
+ raise Failure, 'ASC check expired while awaiting local input; check Apple again' unless (Time.now.utc-Time.iso8601(proof.fetch('checked_utc'))).between?(0,900)
+ # Only standard runtime paths survive. Credentials are process-local;
+ # the command references the environment variable, never its value.
+ ENV.keys.each { |key| ENV.delete(key) unless %w[PATH HOME TMPDIR LANG LC_ALL TERM USER LOGNAME BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_APP_CONFIG].include?(key) }
+ ENV['FASTLANE_OPT_OUT_USAGE']='1'; ENV['FASTLANE_SKIP_UPDATE_CHECK']='1'
+ ENV['FASTLANE_DONT_STORE_PASSWORD']='1'; ENV['JOURNAL_UPLOAD_PASSWORD']=secret
+ ENV['FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD']='@env:JOURNAL_UPLOAD_PASSWORD'
+ STDOUT.reopen(File::NULL,'w'); STDERR.reopen(File::NULL,'w')
+ install_transport_hooks!; self.transport={}
+ config=FastlaneCore::Configuration.create(Pilot::Options.available_options,upload_options(snapshot,version: package.fetch('version'),build: package.fetch('build')))
+ result['stage']='pilot_upload'
+ Pilot::BuildManager.new.upload(config)
+ observed=transport || {}
+ result['status']='tool_reported_success' if transport_success?(observed[:exit_code],observed[:output].to_s) && observed[:errors].to_s.empty?
+ rescue StandardError => error
+ result['error_class']=error.class.name
+ result['error']=redact(error.message,secrets: [secret])
+ ensure
+ result['transport']=transport || {}
+ result['apple_error_codes']=JSON.generate(result).scan(/ITMS-\d+|(?:STATE|ENTITY)_ERROR\.[A-Z_.]+|\(-?\d{1,6}\)/).uniq
+ ENV.replace(saved)
+ secret.replace("\0"*secret.bytesize)
+ STDOUT.reopen(out); STDERR.reopen(err); out.close; err.close
+ end
+ puts JSON.pretty_generate(result)
+ result
+ end
+ end
+ end
+end
diff --git a/fastlane/lib/journal_verify.rb b/fastlane/lib/journal_verify.rb
new file mode 100644
index 00000000..5365f786
--- /dev/null
+++ b/fastlane/lib/journal_verify.rb
@@ -0,0 +1,41 @@
+require 'zip'
+
+module JournalRelease
+ def self.read_plist(path)
+ Plist.parse_xml(run!('/usr/bin/plutil','-convert','xml1','-o','-',path),marshal: false)
+ end
+
+ def self.verify_ipa!(ipa:, version:, build:, sha256:, source_sha:, experimental: false)
+ raise Failure, 'IPA SHA256 mismatch' unless sha256.to_s.match?(/\A[0-9a-f]{64}\z/) && Digest::SHA256.file(ipa).hexdigest==sha256
+ Dir.mktmpdir('journal-ipa-') do |directory|
+ Zip::File.open(ipa) do |zip|
+ zip.each do |entry|
+ destination=File.expand_path(entry.name,directory)
+ raise Failure, 'Unsafe IPA entry' unless destination.start_with?(directory+'/') && !entry.symlink?
+ FileUtils.mkdir_p(File.dirname(destination))
+ entry.extract(entry.name,destination_directory: directory)
+ end
+ end
+ apps=Dir[File.join(directory,'Payload','*.app')]
+ raise Failure, 'IPA must contain exactly one Journal app' unless apps.length==1
+ app=apps.first; info=read_plist(File.join(app,'Info.plist'))
+ validate_info!(info,version: version,build: build,source_sha: source_sha,experimental: experimental)
+ run!('/usr/bin/codesign','--verify','--deep','--strict',app)
+ ent_xml=run!('/usr/bin/codesign','-d','--entitlements',':-',app)
+ ent=Plist.parse_xml(ent_xml,marshal: false)
+ profile=Plist.parse_xml(run!('/usr/bin/openssl','smime','-inform','der','-verify','-noverify','-in',File.join(app,'embedded.mobileprovision')),marshal: false)
+ prefix=File.join(directory,'certificate')
+ run!('/usr/bin/codesign','-d',"--extract-certificates=#{prefix}",app)
+ cert_sha=Digest::SHA1.file(prefix+'0').hexdigest
+ validate_profile!(profile,ent,cert_sha)
+ executable=File.join(app,info.fetch('CFBundleExecutable'))
+ raise Failure, 'IPA must contain arm64 only' unless run!('/usr/bin/lipo','-archs',executable).strip=='arm64'
+ load_commands=run!('/usr/bin/otool','-l',executable)
+ raise Failure, 'Executable is not an iPhoneOS image' unless load_commands.match?(/cmd LC_BUILD_VERSION\s+cmdsize \d+\s+platform (?:2|IOS)\b/)
+ {'app_id'=>APP_ID,'bundle_id'=>BUNDLE,'team_id'=>TEAM,'version'=>version,'build'=>build,
+ 'ipa_sha256'=>sha256,'source_sha'=>info.fetch('JournalSourceRevision'),
+ 'profile_uuid'=>profile.fetch('UUID'),'cross_abi_validated'=>false,
+ 'build_mode'=>info.fetch('JournalBuildMode')}
+ end
+ end
+end