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