fastlane-plugin-pharen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8cd4f080adf3fef449642139ef7ca42ee6f186325ca8948ee05dc7fdab939c3a
4
+ data.tar.gz: 4db24993e8d906973ab4c91e8df26d26bd01104c00c332c3d2419a85b62f4b54
5
+ SHA512:
6
+ metadata.gz: a05b26ce9c1de6c6c1fa251948a7d6af63bd6e89bf4bef1338d7247c6ca37290e7dbbbe5864ab2d83f51450ed719b23ebdd2d6d993e9e0a4140e7891af2137c2
7
+ data.tar.gz: 3b6f71ab17e05f1b1f50488360a7533e2dbfe5757d0b36f536c42daef9f662f092be6a628f2b9e94bee3d4de2b9a4ff6671d6cd9dff164ad095e7253ea0b8f9a
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to `fastlane-plugin-pharen` are documented here.
4
+
5
+ ## 0.1.0 — initial release
6
+
7
+ Three actions, a thin wrapper over the `pharen` CLI (auth, `.pharen.yml` resolution,
8
+ debug-id parsing and server-side dedupe all live there):
9
+
10
+ - **`pharen_release`** — the whole integration in one call after `build_app`: registers
11
+ the release, uploads every dSYM the archive produced, uploads the build, and returns
12
+ the OTA install link. Zero-arg by design — IPA and dSYMs come from the lane context,
13
+ version/build/bundle id are read from the IPA's own `Info.plist`, the commit sha from
14
+ git, org/app from the committed `.pharen.yml`, auth from `PHAREN_AUTH_TOKEN`.
15
+ - **`pharen_upload_build`** — upload the IPA and get the install link back, standalone.
16
+ - **`pharen_upload_symbols`** — upload dSYMs for crash symbolication, standalone. Accepts
17
+ `.dSYM` bundles, dSYM `.zip` files (as produced by `gym`/`download_dsyms`), or raw
18
+ DWARF binaries.
19
+
20
+ **Failure posture:** warn-don't-fail by default — a telemetry step should never be the
21
+ reason a release build fails. Pass `strict: true` (or set `PHAREN_STRICT=1`) in CI lanes
22
+ where an unsymbolicated release must be a hard stop. A missing-dSYM situation always
23
+ warns loudly regardless of mode, since a build shipped without symbols leaves every
24
+ crash it produces unsymbolicated forever.
25
+
26
+ **Auth:** `PHAREN_AUTH_TOKEN` is read from the environment only — the plugin never
27
+ reads, prints, or passes it on a command line.
28
+
29
+ **Dependencies:** none beyond `fastlane` itself at runtime. The plugin shells out to the
30
+ `pharen` CLI and reads its JSON-on-stdout contract; all wire logic (auth, config
31
+ resolution, debug-id parsing, dedupe) lives in the CLI.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucubra LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # fastlane-plugin-pharen
2
+
3
+ Ship an iOS build to Pharen from your Fastfile. One action, `pharen_release`, runs after
4
+ `build_app`: it registers the release, uploads every dSYM for crash symbolication,
5
+ uploads the build, and returns an OTA install link — with nothing to configure in the
6
+ lane.
7
+
8
+ The plugin is a thin wrapper over the `pharen` CLI. Authentication, `.pharen.yml`
9
+ resolution, debug-id parsing, and server-side dedupe all live in the CLI; the plugin only
10
+ derives what the CLI needs from the lane context and the built artifacts. It adds no
11
+ runtime gem dependencies of its own.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ fastlane add_plugin pharen
17
+ ```
18
+
19
+ Once per repository, you also need:
20
+
21
+ - the `pharen` CLI on `PATH` (or point `pharen_binary:` / `PHAREN_BINARY` at it),
22
+ - a committed `.pharen.yml` at the repo root (`org:` and `app:` — no secrets),
23
+ - `PHAREN_AUTH_TOKEN` in the environment (your one secret — see [Configuration](#configuration)).
24
+
25
+ ## Quickstart
26
+
27
+ ```ruby
28
+ lane :beta do
29
+ build_app(scheme: "MyApp")
30
+ pharen_release
31
+ end
32
+ ```
33
+
34
+ `pharen_release` derives everything else: the IPA and dSYMs from the lane context; the
35
+ version, build number, and bundle id from the IPA's own `Info.plist`; the commit sha
36
+ from git. It registers the release, uploads every dSYM the archive produced, uploads the
37
+ build, prints the OTA install link, and returns it (also available in
38
+ `lane_context[SharedValues::PHAREN_INSTALL_URL]`).
39
+
40
+ ## Actions
41
+
42
+ ### `pharen_release`
43
+
44
+ The whole integration in one call after `build_app`.
45
+
46
+ | Option | Env | Default | Description |
47
+ |---|---|---|---|
48
+ | `ipa` | `PHAREN_IPA` | lane context (`IPA_OUTPUT_PATH`) | Path to the `.ipa`. |
49
+ | `dsym_paths` | `PHAREN_DSYM_PATHS` | lane context (`DSYM_OUTPUT_PATH` + `DSYM_PATHS`) | dSYM bundles or `.zip`s to upload. |
50
+ | `pharen_binary` | `PHAREN_BINARY` | `pharen` | Command that invokes the CLI (may include arguments, e.g. `node /path/to/cli.js`). |
51
+ | `strict` | `PHAREN_STRICT` | `false` | Fail the lane on any step's error instead of warning and continuing. |
52
+
53
+ Returns the install link on success, or `nil` when a step is skipped or fails outside
54
+ strict mode.
55
+
56
+ ### `pharen_upload_build`
57
+
58
+ Upload the IPA and get the install link back, standalone.
59
+
60
+ ```ruby
61
+ pharen_upload_build # IPA from the lane context → install link
62
+ ```
63
+
64
+ | Option | Env | Default | Description |
65
+ |---|---|---|---|
66
+ | `ipa` | `PHAREN_IPA` | lane context (`IPA_OUTPUT_PATH`) | Path to the `.ipa`. |
67
+ | `pharen_binary` | `PHAREN_BINARY` | `pharen` | Command that invokes the CLI. |
68
+ | `strict` | `PHAREN_STRICT` | `false` | Fail the lane on error instead of warning and continuing. |
69
+
70
+ ### `pharen_upload_symbols`
71
+
72
+ Upload dSYMs for crash symbolication, standalone.
73
+
74
+ ```ruby
75
+ pharen_upload_symbols # dSYMs from the lane context
76
+ pharen_upload_symbols(dsym_paths: ["path/to/App.dSYM"]) # or explicit
77
+ ```
78
+
79
+ | Option | Env | Default | Description |
80
+ |---|---|---|---|
81
+ | `dsym_paths` | `PHAREN_DSYM_PATHS` | lane context (`DSYM_OUTPUT_PATH` + `DSYM_PATHS`) | `.dSYM` bundles, dSYM `.zip`s (as produced by `gym`/`download_dsyms`), or raw DWARF binaries. |
82
+ | `pharen_binary` | `PHAREN_BINARY` | `pharen` | Command that invokes the CLI. |
83
+ | `strict` | `PHAREN_STRICT` | `false` | Fail the lane on error instead of warning and continuing. |
84
+
85
+ ## Configuration
86
+
87
+ Two things live outside the lane, and only one of them is a secret:
88
+
89
+ - **`.pharen.yml`** — committed at the repo root, holding `org:` and `app:`. No secrets
90
+ belong in it. The CLI walks up from its working directory to find it, so it resolves
91
+ whether fastlane runs from the repo root or the `fastlane/` directory.
92
+ - **`PHAREN_AUTH_TOKEN`** — your one secret, read from the environment (a CI secret in
93
+ practice). Only the CLI reads it, and only from the environment. The plugin never
94
+ reads, prints, or passes the token, so it never lands in a log line or a process
95
+ argument list.
96
+
97
+ ## Failure posture
98
+
99
+ The default is **warn-don't-fail**: if a step errors, the plugin logs a loud warning and
100
+ lets the build continue. A telemetry step should never be the reason a release build
101
+ fails.
102
+
103
+ Pass `strict: true` (or set `PHAREN_STRICT=1`) in CI lanes where an unsymbolicated or
104
+ undistributed release must be a hard stop; then any step's error fails the lane.
105
+
106
+ Two things hold regardless of mode:
107
+
108
+ - **Missing dSYMs always warn loudly.** A build shipped without its symbols produces
109
+ crashes that can never be read, and uploading symbols later does not retroactively
110
+ symbolicate the crashes it already caused. Archive with
111
+ `DEBUG_INFORMATION_FORMAT=dwarf-with-dsym` so the dSYMs exist to upload.
112
+ - **Re-runs are safe.** The server dedupes symbols by debug id, and the CLI exits 0 when
113
+ every file is already uploaded — so retrying a lane never double-uploads or errors on
114
+ work already done.
115
+
116
+ ## Troubleshooting
117
+
118
+ - **`pharen` not found.** The CLI isn't on `PATH`. Install it and make `pharen`
119
+ resolvable, or point `pharen_binary:` / `PHAREN_BINARY` at it (it may include
120
+ arguments, e.g. `node /path/to/cli.js`).
121
+ - **"no IPA" or "no dSYMs in the lane context".** Run the action after `build_app`/`gym`
122
+ (and `download_dsyms`, if you fetch symbols from App Store Connect), or pass `ipa:` /
123
+ `dsym_paths:` explicitly.
124
+ - **Release registration warns but the build still uploads.** Expected in warn mode:
125
+ registration records provenance, so a failed registration degrades metadata without
126
+ stopping distribution. Use `strict: true` if you need it to stop the lane.
127
+ - **Not using fastlane?** The `pharen` CLI runs the same three steps directly. See the
128
+ documentation at <https://pharen.ai/docs>.
129
+
130
+ ## License
131
+
132
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,176 @@
1
+ # pharen_release — the whole Pharen integration in ONE bare call after build_app:
2
+ # register the release, upload every dSYM, upload the IPA, return the install link.
3
+ #
4
+ # lane :beta do
5
+ # build_app(scheme: "MyApp")
6
+ # pharen_release
7
+ # end
8
+ #
9
+ # Zero-arg: the IPA and dSYMs default from the lane context (gym/download_dsyms);
10
+ # version / build number / bundle id are read from the IPA's own Info.plist; the
11
+ # commit sha from git; org/app from the committed .pharen.yml; auth from
12
+ # PHAREN_AUTH_TOKEN (env-only, never echoed). It folds Pharen's three CLI steps
13
+ # (releases new → upload-symbols → upload-build) into one, so registering a release
14
+ # is never a step you have to remember to run.
15
+ #
16
+ # SYMBOLS ARE NOT OPTIONAL: a build shipped without its dSYMs leaves every crash it
17
+ # ever produces unsymbolicated (late upload does not retroactively fix issues). In
18
+ # warn mode a missing-dSYM situation is a LOUD warning and the build still uploads;
19
+ # strict mode aborts the lane.
20
+
21
+ require "fastlane/action"
22
+ require_relative "../helper/pharen_helper"
23
+
24
+ module Fastlane
25
+ module Actions
26
+ module SharedValues
27
+ PHAREN_INSTALL_URL = :PHAREN_INSTALL_URL unless const_defined?(:PHAREN_INSTALL_URL)
28
+ end
29
+
30
+ class PharenReleaseAction < Action
31
+ def self.run(params)
32
+ helper = Helper::PharenHelper
33
+
34
+ # -- gather: IPA + identity (the customer passes nothing) --------------------
35
+ ipa = params[:ipa] || Actions.lane_context[SharedValues::IPA_OUTPUT_PATH]
36
+ if ipa.nil? || ipa.to_s.empty?
37
+ helper.fail_or_warn(params[:strict], "pharen_release: no IPA — run after build_app/gym, or pass ipa:")
38
+ return nil
39
+ end
40
+ ipa = File.expand_path(ipa.to_s)
41
+
42
+ begin
43
+ info = helper.read_ipa_info(ipa)
44
+ rescue Helper::PharenHelper::Failure => e
45
+ helper.fail_or_warn(params[:strict], "pharen_release: #{e.message}")
46
+ return nil
47
+ end
48
+
49
+ # -- step 1: register the release (idempotent server-side; commit = provenance)
50
+ release_args = ["releases", "new", "--platform", "ios", "--version", info[:version], "--build", info[:build]]
51
+ sha = helper.git_commit_sha
52
+ release_args += ["--commit", sha] if sha
53
+ release = helper.run_pharen(params[:pharen_binary], release_args)
54
+ unless release[:ok]
55
+ # Not fatal in warn mode: ingest also binds releases lazily, so a failed
56
+ # registration degrades provenance, not the pipeline.
57
+ helper.fail_or_warn(params[:strict], helper.cli_failure_message("pharen releases new", release))
58
+ return nil if params[:strict]
59
+ end
60
+
61
+ # -- step 2: symbols (every dSYM the lane produced) ---------------------------
62
+ dsym_inputs = params[:dsym_paths]
63
+ if dsym_inputs.nil? || Array(dsym_inputs).empty?
64
+ candidates = []
65
+ candidates << Actions.lane_context[SharedValues::DSYM_OUTPUT_PATH]
66
+ candidates.concat(Array(Actions.lane_context[SharedValues::DSYM_PATHS]))
67
+ dsym_inputs = candidates.compact.uniq
68
+ end
69
+ if Array(dsym_inputs).empty?
70
+ helper.fail_or_warn(
71
+ params[:strict],
72
+ "pharen_release: no dSYMs in the lane context — crashes from this build " \
73
+ "can NEVER symbolicate. Archive with DEBUG_INFORMATION_FORMAT=dwarf-with-dsym, " \
74
+ "or pass dsym_paths:"
75
+ )
76
+ else
77
+ begin
78
+ dsyms = helper.expand_dsym_inputs(dsym_inputs)
79
+ symbols = helper.run_pharen(params[:pharen_binary], ["upload-symbols", *dsyms])
80
+ if symbols[:ok]
81
+ summary = symbols[:json].is_a?(Hash) ? symbols[:json]["summary"] : nil
82
+ UI.success("pharen_release: symbols — #{summary || "uploaded #{dsyms.length} dSYM(s)"}")
83
+ else
84
+ helper.fail_or_warn(params[:strict], helper.cli_failure_message("pharen upload-symbols", symbols))
85
+ end
86
+ rescue Helper::PharenHelper::Failure => e
87
+ helper.fail_or_warn(params[:strict], "pharen_release: #{e.message}")
88
+ end
89
+ end
90
+
91
+ # -- step 3: the build itself → install link ----------------------------------
92
+ build_args = [
93
+ "upload-build",
94
+ "--platform", "ios",
95
+ "--version", info[:version],
96
+ "--build", info[:build],
97
+ "--bundle-id", info[:bundle_id],
98
+ ipa
99
+ ]
100
+ build = helper.run_pharen(params[:pharen_binary], build_args)
101
+ unless build[:ok]
102
+ helper.fail_or_warn(params[:strict], helper.cli_failure_message("pharen upload-build", build))
103
+ return nil
104
+ end
105
+
106
+ install_url = build[:json].is_a?(Hash) ? build[:json]["install_url"] : nil
107
+ Actions.lane_context[SharedValues::PHAREN_INSTALL_URL] = install_url if install_url
108
+ UI.success("pharen_release: #{info[:bundle_id]} #{info[:version]} (#{info[:build]}) registered + symbolicated + distributed")
109
+ UI.important("Install (Safari on the phone): #{install_url}") if install_url
110
+ install_url
111
+ end
112
+
113
+ #####################################################
114
+ # @!group Documentation
115
+ #####################################################
116
+
117
+ def self.description
118
+ "One call after build_app: register the release, upload symbols + build to Pharen, return the install link"
119
+ end
120
+
121
+ def self.authors
122
+ ["Pharen (Lucubra LLC)"]
123
+ end
124
+
125
+ def self.details
126
+ "Orchestrates the Pharen CLI's three steps (releases new → upload-symbols → " \
127
+ "upload-build) with everything derived: IPA + dSYMs from the lane context, " \
128
+ "version/build/bundle id from the IPA's Info.plist, commit sha from git, " \
129
+ "org/app from the committed .pharen.yml, auth from PHAREN_AUTH_TOKEN. " \
130
+ "Missing dSYMs warn LOUDLY (or abort under strict) — a build without " \
131
+ "symbols ships crashes that can never be read."
132
+ end
133
+
134
+ def self.available_options
135
+ [
136
+ FastlaneCore::ConfigItem.new(key: :ipa,
137
+ env_name: "PHAREN_IPA",
138
+ description: "Path to the .ipa. Defaults to what gym/build_app left in the lane context",
139
+ optional: true,
140
+ type: String),
141
+ FastlaneCore::ConfigItem.new(key: :dsym_paths,
142
+ env_name: "PHAREN_DSYM_PATHS",
143
+ description: "dSYM bundles/zips to upload. Defaults to gym's DSYM_OUTPUT_PATH + download_dsyms' DSYM_PATHS",
144
+ optional: true,
145
+ is_string: false),
146
+ FastlaneCore::ConfigItem.new(key: :pharen_binary,
147
+ env_name: "PHAREN_BINARY",
148
+ description: "Command that invokes the Pharen CLI (may include arguments, e.g. `node /path/to/cli.js`)",
149
+ optional: true,
150
+ default_value: "pharen",
151
+ type: String),
152
+ FastlaneCore::ConfigItem.new(key: :strict,
153
+ env_name: "PHAREN_STRICT",
154
+ description: "Fail the lane on any step's error (true) instead of warning and continuing (false)",
155
+ optional: true,
156
+ default_value: false,
157
+ is_string: false)
158
+ ]
159
+ end
160
+
161
+ def self.output
162
+ [
163
+ ["PHAREN_INSTALL_URL", "The itms-services install link answered by the Pharen control plane"]
164
+ ]
165
+ end
166
+
167
+ def self.return_value
168
+ "The install_url string on success; nil on skipped/failed-but-not-strict"
169
+ end
170
+
171
+ def self.is_supported?(platform)
172
+ platform == :ios
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,118 @@
1
+ # pharen_upload_build — upload the IPA to Pharen's dl distribution and get the
2
+ # install link back.
3
+ #
4
+ # Zero-arg capable: the IPA defaults from gym/build_app's SharedValues::IPA_OUTPUT_PATH,
5
+ # and version / build number / bundle id are read out of the IPA's own Info.plist
6
+ # (unzip + plutil — dependency-free), so the customer passes nothing. org/app come from
7
+ # the committed .pharen.yml; auth from PHAREN_AUTH_TOKEN (env-only, never echoed).
8
+
9
+ require "fastlane/action"
10
+ require_relative "../helper/pharen_helper"
11
+
12
+ module Fastlane
13
+ module Actions
14
+ module SharedValues
15
+ PHAREN_INSTALL_URL = :PHAREN_INSTALL_URL unless const_defined?(:PHAREN_INSTALL_URL)
16
+ end
17
+
18
+ class PharenUploadBuildAction < Action
19
+ def self.run(params)
20
+ helper = Helper::PharenHelper
21
+
22
+ ipa = params[:ipa] || Actions.lane_context[SharedValues::IPA_OUTPUT_PATH]
23
+ if ipa.nil? || ipa.to_s.empty?
24
+ helper.fail_or_warn(
25
+ params[:strict],
26
+ "pharen_upload_build: no IPA — run after build_app/gym, or pass ipa:"
27
+ )
28
+ return nil
29
+ end
30
+ ipa = File.expand_path(ipa.to_s)
31
+
32
+ begin
33
+ info = helper.read_ipa_info(ipa)
34
+ rescue Helper::PharenHelper::Failure => e
35
+ helper.fail_or_warn(params[:strict], "pharen_upload_build: #{e.message}")
36
+ return nil
37
+ end
38
+
39
+ args = [
40
+ "upload-build",
41
+ "--platform", "ios",
42
+ "--version", info[:version],
43
+ "--build", info[:build],
44
+ "--bundle-id", info[:bundle_id],
45
+ ipa
46
+ ]
47
+ result = helper.run_pharen(params[:pharen_binary], args)
48
+ unless result[:ok]
49
+ helper.fail_or_warn(params[:strict], helper.cli_failure_message("pharen upload-build", result))
50
+ return nil
51
+ end
52
+
53
+ install_url = result[:json].is_a?(Hash) ? result[:json]["install_url"] : nil
54
+ Actions.lane_context[SharedValues::PHAREN_INSTALL_URL] = install_url if install_url
55
+ UI.success("pharen_upload_build: uploaded #{info[:version]} (#{info[:build]}) #{info[:bundle_id]}")
56
+ UI.important("Install (Safari on the phone): #{install_url}") if install_url
57
+ install_url
58
+ end
59
+
60
+ #####################################################
61
+ # @!group Documentation
62
+ #####################################################
63
+
64
+ def self.description
65
+ "Upload the IPA to Pharen dl distribution and return the OTA install link (zero-arg after gym)"
66
+ end
67
+
68
+ def self.authors
69
+ ["Pharen (Lucubra LLC)"]
70
+ end
71
+
72
+ def self.details
73
+ "Thin wrapper over `pharen upload-build`: the IPA defaults from " \
74
+ "SharedValues::IPA_OUTPUT_PATH; version, build number and bundle id are " \
75
+ "extracted from the IPA's Info.plist so nothing needs passing. The server " \
76
+ "owns the dl layout, opaque slug and manifest.plist; the returned " \
77
+ "install_url is this action's return value and lands in " \
78
+ "lane_context[SharedValues::PHAREN_INSTALL_URL]."
79
+ end
80
+
81
+ def self.available_options
82
+ [
83
+ FastlaneCore::ConfigItem.new(key: :ipa,
84
+ env_name: "PHAREN_IPA",
85
+ description: "Path to the .ipa. Defaults to what gym/build_app left in the lane context",
86
+ optional: true,
87
+ type: String),
88
+ FastlaneCore::ConfigItem.new(key: :pharen_binary,
89
+ env_name: "PHAREN_BINARY",
90
+ description: "Command that invokes the Pharen CLI (may include arguments, e.g. `node /path/to/cli.js`)",
91
+ optional: true,
92
+ default_value: "pharen",
93
+ type: String),
94
+ FastlaneCore::ConfigItem.new(key: :strict,
95
+ env_name: "PHAREN_STRICT",
96
+ description: "Fail the lane on error (true) instead of warning and continuing (false)",
97
+ optional: true,
98
+ default_value: false,
99
+ is_string: false)
100
+ ]
101
+ end
102
+
103
+ def self.output
104
+ [
105
+ ["PHAREN_INSTALL_URL", "The itms-services install link answered by the Pharen control plane"]
106
+ ]
107
+ end
108
+
109
+ def self.return_value
110
+ "The install_url string on success; nil on skipped/failed-but-not-strict"
111
+ end
112
+
113
+ def self.is_supported?(platform)
114
+ platform == :ios
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,108 @@
1
+ # pharen_upload_symbols — upload dSYMs to Pharen for crash symbolication.
2
+ #
3
+ # Zero-arg capable: with no options it takes the dSYMs the lane already produced —
4
+ # gym/build_app's SharedValues::DSYM_OUTPUT_PATH and download_dsyms'
5
+ # SharedValues::DSYM_PATHS. The CLI needs ONLY paths: debug ids are parsed from the
6
+ # binaries, org/app come from the customer's committed .pharen.yml, auth from
7
+ # PHAREN_AUTH_TOKEN in the environment (never echoed, never on a command line).
8
+
9
+ require "fastlane/action"
10
+ require_relative "../helper/pharen_helper"
11
+
12
+ module Fastlane
13
+ module Actions
14
+ class PharenUploadSymbolsAction < Action
15
+ def self.run(params)
16
+ helper = Helper::PharenHelper
17
+
18
+ paths = params[:dsym_paths]
19
+ paths = default_dsym_paths if paths.nil? || Array(paths).empty?
20
+ if Array(paths).empty?
21
+ helper.fail_or_warn(
22
+ params[:strict],
23
+ "pharen_upload_symbols: no dSYMs to upload — run after build_app/gym or " \
24
+ "download_dsyms, or pass dsym_paths"
25
+ )
26
+ return nil
27
+ end
28
+
29
+ begin
30
+ expanded = helper.expand_dsym_inputs(paths)
31
+ rescue Helper::PharenHelper::Failure => e
32
+ helper.fail_or_warn(params[:strict], "pharen_upload_symbols: #{e.message}")
33
+ return nil
34
+ end
35
+
36
+ result = helper.run_pharen(params[:pharen_binary], ["upload-symbols"] + expanded)
37
+ unless result[:ok]
38
+ helper.fail_or_warn(params[:strict], helper.cli_failure_message("pharen upload-symbols", result))
39
+ return nil
40
+ end
41
+
42
+ summary = result[:json].is_a?(Hash) ? result[:json]["summary"] : nil
43
+ UI.success("pharen_upload_symbols: #{summary || 'symbols uploaded'}")
44
+ result[:json]
45
+ end
46
+
47
+ # The two places fastlane already put dSYMs, in build order: gym's zip, then any
48
+ # App Store Connect downloads. Duplicates collapse; the CLI dedupes by debug id
49
+ # anyway (re-uploads answer 'duplicate' and exit 0, so this is always re-run safe).
50
+ def self.default_dsym_paths
51
+ candidates = []
52
+ candidates << Actions.lane_context[SharedValues::DSYM_OUTPUT_PATH]
53
+ candidates.concat(Array(Actions.lane_context[SharedValues::DSYM_PATHS]))
54
+ candidates.compact.map(&:to_s).reject(&:empty?).uniq
55
+ end
56
+
57
+ #####################################################
58
+ # @!group Documentation
59
+ #####################################################
60
+
61
+ def self.description
62
+ "Upload dSYMs to Pharen for crash symbolication (zero-arg after gym/download_dsyms)"
63
+ end
64
+
65
+ def self.authors
66
+ ["Pharen (Lucubra LLC)"]
67
+ end
68
+
69
+ def self.details
70
+ "Thin wrapper over `pharen upload-symbols`: dSYM paths default from " \
71
+ "SharedValues::DSYM_OUTPUT_PATH (gym) and SharedValues::DSYM_PATHS " \
72
+ "(download_dsyms); .zip inputs are expanded to .dSYM bundles. Debug ids, " \
73
+ "org/app (.pharen.yml) and auth (PHAREN_AUTH_TOKEN) are the CLI's job. " \
74
+ "Server dedupes by debug id, so re-runs are safe."
75
+ end
76
+
77
+ def self.available_options
78
+ [
79
+ FastlaneCore::ConfigItem.new(key: :dsym_paths,
80
+ env_name: "PHAREN_DSYM_PATHS",
81
+ description: "dSYM inputs (.dSYM bundles, dSYM .zip files, or raw DWARF binaries). Defaults to what gym/download_dsyms left in the lane context",
82
+ optional: true,
83
+ type: Array),
84
+ FastlaneCore::ConfigItem.new(key: :pharen_binary,
85
+ env_name: "PHAREN_BINARY",
86
+ description: "Command that invokes the Pharen CLI (may include arguments, e.g. `node /path/to/cli.js`)",
87
+ optional: true,
88
+ default_value: "pharen",
89
+ type: String),
90
+ FastlaneCore::ConfigItem.new(key: :strict,
91
+ env_name: "PHAREN_STRICT",
92
+ description: "Fail the lane on error (true) instead of warning and continuing (false)",
93
+ optional: true,
94
+ default_value: false,
95
+ is_string: false)
96
+ ]
97
+ end
98
+
99
+ def self.return_value
100
+ "The CLI's parsed JSON ({ archives, summary }) on success; nil on skipped/failed-but-not-strict"
101
+ end
102
+
103
+ def self.is_supported?(platform)
104
+ platform == :ios
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,195 @@
1
+ # PharenHelper — everything the pharen_* actions share. The plugin is a THIN wrapper:
2
+ # all wire logic (auth, .pharen.yml, debug-id parsing, dedupe) lives in the `pharen`
3
+ # CLI. This helper only
4
+ # * derives metadata the CLI needs (Info.plist out of the IPA, git commit sha),
5
+ # * normalizes dSYM inputs (gym/download_dsyms hand over .zip files; the CLI wants
6
+ # .dSYM bundles or raw DWARF binaries),
7
+ # * shells out to the CLI and interprets its JSON-on-stdout contract.
8
+ #
9
+ # Dependency-free by design: macOS system tools only (unzip, plutil, git) — every box
10
+ # that can produce an IPA has them — so the gem adds NO runtime dependencies.
11
+ #
12
+ # SECURITY: PHAREN_AUTH_TOKEN is read by the CLI from the environment. This helper never
13
+ # reads, prints, or passes the token — no command line ever contains it.
14
+
15
+ require "fastlane_core/ui/ui"
16
+ require "json"
17
+ require "open3"
18
+ require "shellwords"
19
+ require "tmpdir"
20
+
21
+ module Fastlane
22
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
23
+
24
+ module Helper
25
+ class PharenHelper
26
+ # Raised for local preparation problems (bad IPA, missing dSYM, unzip failure).
27
+ # Actions translate it via `fail_or_warn` — never let it escape raw.
28
+ class Failure < StandardError; end
29
+
30
+ # The app's own Info.plist sits exactly at Payload/<Name>.app/Info.plist.
31
+ # [^/]+ cannot span "/", so nested bundles (Watch apps, app extensions,
32
+ # frameworks) never match.
33
+ INFO_PLIST_ENTRY = %r{\APayload/[^/]+\.app/Info\.plist\z}
34
+
35
+ class << self
36
+ # -- IPA introspection ---------------------------------------------------------
37
+
38
+ # IPA -> { version:, build:, bundle_id: } (CFBundleShortVersionString,
39
+ # CFBundleVersion, CFBundleIdentifier), so the customer passes nothing.
40
+ # unzip streams the (usually binary) plist; plutil converts it to JSON on stdout.
41
+ def read_ipa_info(ipa_path)
42
+ raise Failure, "IPA not found at #{ipa_path}" unless File.exist?(ipa_path)
43
+
44
+ entry = info_plist_entry(ipa_path)
45
+ raw, _err, status = capture(["unzip", "-p", ipa_path, entry])
46
+ raise Failure, "could not read #{entry} from #{ipa_path} (unzip exit #{status})" unless status == 0
47
+
48
+ json, perr, pstatus = capture(["plutil", "-convert", "json", "-o", "-", "-"], stdin: raw)
49
+ raise Failure, "plutil could not parse Info.plist from #{ipa_path}: #{perr.to_s.strip}" unless pstatus == 0
50
+
51
+ plist = JSON.parse(json.force_encoding("UTF-8"))
52
+ info = {
53
+ version: plist["CFBundleShortVersionString"],
54
+ build: plist["CFBundleVersion"],
55
+ bundle_id: plist["CFBundleIdentifier"]
56
+ }
57
+ missing = info.select { |_k, v| v.nil? || v.to_s.empty? }.keys
58
+ unless missing.empty?
59
+ raise Failure, "Info.plist in #{ipa_path} is missing #{missing.join(', ')}"
60
+ end
61
+ info
62
+ end
63
+
64
+ # Zip entry name of the app's Info.plist (shallowest Payload/*.app only).
65
+ def info_plist_entry(ipa_path)
66
+ listing, err, status = capture(["unzip", "-Z1", ipa_path])
67
+ raise Failure, "could not list #{ipa_path} (unzip -Z1 exit #{status}): #{err.to_s.strip}" unless status == 0
68
+
69
+ entry = listing.split("\n").find { |line| line.match?(INFO_PLIST_ENTRY) }
70
+ raise Failure, "no Payload/*.app/Info.plist inside #{ipa_path} — is this an IPA?" unless entry
71
+ entry
72
+ end
73
+
74
+ # -- dSYM normalization --------------------------------------------------------
75
+
76
+ # gym's DSYM_OUTPUT_PATH and download_dsyms' DSYM_PATHS are .zip files; the CLI
77
+ # takes .dSYM bundles (or raw DWARF binaries). Expand zips, absolutize the rest —
78
+ # paths must be absolute because run_pharen may chdir for .pharen.yml resolution.
79
+ def expand_dsym_inputs(paths)
80
+ Array(paths).flat_map do |path|
81
+ abs = File.expand_path(path.to_s)
82
+ raise Failure, "dSYM path not found: #{path}" unless File.exist?(abs)
83
+ abs.end_with?(".zip") ? unzip_dsyms(abs) : [abs]
84
+ end
85
+ end
86
+
87
+ # Unpack a dSYM zip into a fresh temp dir and return the .dSYM bundles inside.
88
+ # The temp dir is left for the OS to reap — the CLI reads from it right after.
89
+ def unzip_dsyms(zip_path)
90
+ dest = Dir.mktmpdir("pharen-dsyms-")
91
+ _out, err, status = capture(["unzip", "-q", "-o", zip_path, "-d", dest])
92
+ raise Failure, "could not unzip #{zip_path}: #{err.to_s.strip}" unless status == 0
93
+
94
+ found = Dir.glob(File.join(dest, "**", "*.dSYM")).sort
95
+ raise Failure, "no .dSYM bundles inside #{zip_path}" if found.empty?
96
+ found
97
+ end
98
+
99
+ # -- Pharen CLI invocation -----------------------------------------------------
100
+
101
+ # Run `<binary> <args...>` and interpret the CLI's contract: JSON on stdout,
102
+ # exit 0 on success. `binary` is Shellwords-split so `pharen_binary` may carry a
103
+ # command with arguments (e.g. "node /path/to/cli.js"). Never raises on CLI
104
+ # failure — callers decide strict-vs-warn from the returned hash.
105
+ def run_pharen(binary, args, chdir: nil)
106
+ argv = Shellwords.split(binary.to_s) + args.map(&:to_s)
107
+ dir = chdir || project_dir
108
+ UI.command(Shellwords.join(argv)) # never contains PHAREN_AUTH_TOKEN (env-only)
109
+ out, err, status = capture(argv, chdir: dir)
110
+ json = parse_json(out)
111
+ { ok: status == 0, status: status, stdout: out, stderr: err, json: json }
112
+ rescue Errno::ENOENT => e
113
+ # Spawn failed: binary (or chdir target) does not exist.
114
+ { ok: false, status: nil, stdout: "", stderr: e.message, json: nil, not_found: true }
115
+ end
116
+
117
+ # One human line for a failed run_pharen result. Prefers the CLI's own
118
+ # { error: { code, message } } JSON; falls back to stderr, then the exit code.
119
+ def cli_failure_message(step, result)
120
+ error = result[:json].is_a?(Hash) ? result[:json]["error"] : nil
121
+ if error.is_a?(Hash) && error["message"]
122
+ "#{step} failed: #{error['message']} (#{error['code']})"
123
+ elsif result[:not_found]
124
+ "#{step} failed: #{result[:stderr]} — install the Pharen CLI and ensure `pharen` is on PATH " \
125
+ "(see https://pharen.ai/docs for install instructions), or set the pharen_binary option"
126
+ else
127
+ detail = result[:stderr].to_s.strip
128
+ detail = result[:stdout].to_s.strip if detail.empty?
129
+ suffix = detail.empty? ? "" : ": #{detail.split("\n").last(3).join(' / ')}"
130
+ "#{step} failed (exit #{result[:status]})#{suffix}"
131
+ end
132
+ end
133
+
134
+ # Where to run the CLI so it finds the customer's committed .pharen.yml.
135
+ # fastlane executes lanes from the fastlane/ directory (Dir.chdir in
136
+ # LaneManager), but .pharen.yml lives at the repo root by convention — step up
137
+ # one level when that is clearly the layout. Explicit --org/--app flags are the
138
+ # escape hatch for exotic layouts.
139
+ # (The CLI itself also walks up from its cwd looking for .pharen.yml, so this
140
+ # special-case is now a belt-and-suspenders optimization, not the only path —
141
+ # kept as-is since it still resolves correctly and needs no change.)
142
+ def project_dir
143
+ cwd = Dir.pwd
144
+ return cwd if File.exist?(File.join(cwd, ".pharen.yml"))
145
+ parent = File.expand_path("..", cwd)
146
+ if File.basename(cwd) == "fastlane" && File.exist?(File.join(parent, ".pharen.yml"))
147
+ parent
148
+ else
149
+ cwd
150
+ end
151
+ end
152
+
153
+ # -- misc ------------------------------------------------------------------------
154
+
155
+ # HEAD sha for `releases new --commit`; nil outside a git checkout (fine — the
156
+ # flag is optional provenance).
157
+ def git_commit_sha
158
+ out, _err, status = capture(%w[git rev-parse HEAD])
159
+ return nil unless status == 0
160
+ sha = out.strip
161
+ sha.empty? ? nil : sha
162
+ end
163
+
164
+ # strict: abort the lane; default: warn loudly and let the build continue —
165
+ # a telemetry step should never be the reason a release build fails.
166
+ def fail_or_warn(strict, message)
167
+ if strict
168
+ UI.user_error!(message)
169
+ else
170
+ UI.important("#{message} — continuing because strict is false")
171
+ end
172
+ end
173
+
174
+ # -- plumbing (the single shell-out seam; specs stub exactly this) ---------------
175
+
176
+ # argv -> [stdout, stderr, exitstatus]; binmode because plists are binary.
177
+ def capture(argv, stdin: nil, chdir: nil)
178
+ options = { binmode: true }
179
+ options[:stdin_data] = stdin unless stdin.nil?
180
+ options[:chdir] = chdir unless chdir.nil?
181
+ out, err, status = Open3.capture3(*argv, **options)
182
+ [out, err, status.exitstatus]
183
+ end
184
+
185
+ private
186
+
187
+ def parse_json(text)
188
+ JSON.parse(text)
189
+ rescue JSON::ParserError, TypeError
190
+ nil
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module Pharen
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,18 @@
1
+ # fastlane-plugin-pharen — standard fastlane plugin entry point (`fastlane add_plugin pharen`).
2
+ # Loads every action + helper under this directory; fastlane discovers actions by class name.
3
+
4
+ require "fastlane/plugin/pharen/version"
5
+
6
+ module Fastlane
7
+ module Pharen
8
+ # Return all .rb files inside the "actions" and "helper" directory.
9
+ def self.all_classes
10
+ Dir[File.expand_path("**/{actions,helper}/*.rb", File.dirname(__FILE__))]
11
+ end
12
+ end
13
+ end
14
+
15
+ # By default we want to import all available actions and helpers.
16
+ Fastlane::Pharen.all_classes.each do |current|
17
+ require current
18
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fastlane-plugin-pharen
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Pharen (Lucubra LLC)
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fastlane
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 2.220.0
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 2.220.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: 'Fastlane plugin for Pharen (https://pharen.ai): a thin wrapper over
55
+ the `pharen` CLI that adds one zero-argument action, pharen_release, after build_app
56
+ — it registers the release, uploads every dSYM, uploads the build, and returns the
57
+ OTA install link. Warn-don''t-fail by default, so a telemetry step never fails your
58
+ release build; `strict: true` opts a lane into hard failure where an unsymbolicated
59
+ release must be a stop.'
60
+ email: hello@pharen.ai
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE
67
+ - README.md
68
+ - lib/fastlane/plugin/pharen.rb
69
+ - lib/fastlane/plugin/pharen/actions/pharen_release_action.rb
70
+ - lib/fastlane/plugin/pharen/actions/pharen_upload_build_action.rb
71
+ - lib/fastlane/plugin/pharen/actions/pharen_upload_symbols_action.rb
72
+ - lib/fastlane/plugin/pharen/helper/pharen_helper.rb
73
+ - lib/fastlane/plugin/pharen/version.rb
74
+ homepage: https://pharen.ai
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ homepage_uri: https://pharen.ai
79
+ rubygems_mfa_required: 'true'
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '3.0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 4.0.11
95
+ specification_version: 4
96
+ summary: 'Pharen build distribution + crash symbols from your lane: pharen_release
97
+ / pharen_upload_build / pharen_upload_symbols'
98
+ test_files: []