fastlane-plugin-appcircle_publish 0.1.0.beta.1

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: d2a731169ee6e19ffa2c1faf2d552cf6464abff3ed839b4231d6de7ed6587897
4
+ data.tar.gz: 346b250fbba4003f3c0b22002eebffbc9b750cce16b2fbb1c9c1b7d2fcd4b89c
5
+ SHA512:
6
+ metadata.gz: ec418da783e2feb28ff2cdf437bb8815523ae9476d78e823f1609290e684277f24af3e164775cc2eb3bc0695ed977f67a8d6e92f0d594974b316322683c12d2c
7
+ data.tar.gz: 2d4b73ea36345d73630c1d14318365627c768ff2dd4d5c8889e37f682f96fa4871199fb9c2ef3f26bbd5e7abd898dbd12d6e90bcd8a8802bbe8b4431c8254dc6
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Burak Yıldırım <buraky@appcircle.io>
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,148 @@
1
+ ## Appcircle Publish
2
+
3
+ [![fastlane Plugin Badge](https://rawcdn.githack.com/fastlane/fastlane/master/fastlane/assets/plugin-badge.svg)](https://rubygems.org/gems/fastlane-plugin-appcircle_publish)
4
+
5
+ Upload an application binary to an Appcircle **Publish** profile and/or trigger
6
+ its publish flow (app store publishing) directly from your Fastlane pipeline.
7
+
8
+ Learn more about [Appcircle Publish](https://appcircle.io/publish-to-stores?utm_source=fastlane&utm_medium=plugin&utm_campaign=publish).
9
+
10
+ ## System Requirements
11
+
12
+ **Compatible Agents:**
13
+
14
+ - macOS 14.2, 14.5
15
+
16
+ **Supported Version:**
17
+
18
+ - Fastlane 2.222.0
19
+ - Ruby 3.2.2
20
+
21
+ Note: Both **Appcircle Cloud** and **self-hosted** Appcircle installations are supported. See [Self-Hosted Appcircle](#self-hosted-appcircle) below to configure custom endpoints.
22
+
23
+ ### Generating/Managing the Personal API Tokens
24
+
25
+ To generate a Personal API Token:
26
+
27
+ 1. Go to the My Organization screen (second option at the bottom left).
28
+ 2. Find the Personal API Token section in the top right corner.
29
+ 3. Press the "Generate Token" button to generate your first token.
30
+
31
+ ![Token Generation](<https://cdn.appcircle.io/docs/assets/image%20(164).png>)
32
+
33
+ ## What the action does
34
+
35
+ The action has two independent switches — `upload` and `publish` — both default
36
+ to `false`. **You must enable at least one.** Create the Publish profile in
37
+ Appcircle first; the action targets it by name (profile names are unique per
38
+ platform).
39
+
40
+ | `upload` | `publish` | Behavior |
41
+ |:--------:|:---------:|----------|
42
+ | `true` | `false` | Upload `appPath` as a new app version on the profile. |
43
+ | `false` | `true` | Trigger the publish flow for the profile's **current release candidate**. |
44
+ | `true` | `true` | Upload `appPath`, **mark the new version as release candidate**, then trigger the publish flow for it. |
45
+ | `false` | `false` | Error — nothing to do. |
46
+
47
+ **Rules:**
48
+
49
+ - **In-progress guard:** if a publish is already running for the target profile, the action does **not** start a new one and fails fast.
50
+ - **Release candidate:** when both `upload` and `publish` are `true`, the freshly uploaded version is automatically marked as the release candidate before it is published. In publish-only mode, the profile's existing release candidate is published.
51
+ - **Progress:** while publishing, the action polls the publish status and prints step-by-step progress (with status icons) until the flow succeeds or fails.
52
+
53
+ > **Manual-approval steps:** if the profile's publish flow contains a manual step (e.g. "Get Approval via Email"), the flow waits for that action and the plugin keeps polling until it completes or the poll times out. For CI use, prefer publish flows without manual gates.
54
+
55
+ ### Getting Started
56
+
57
+ This project is a [_fastlane_](https://github.com/fastlane/fastlane) plugin. To get started with `appcircle_publish`, add it to your project by running:
58
+
59
+ ```bash
60
+ fastlane add_plugin appcircle_publish
61
+ ```
62
+
63
+ **Upload only:**
64
+
65
+ ```ruby
66
+ lane :upload_to_publish do
67
+ appcircle_publish(
68
+ personalAPIToken: "$(AC_PERSONAL_API_TOKEN)",
69
+ platform: "$(AC_PLATFORM)", # "ios" or "android"
70
+ publishProfile: "$(AC_PUBLISH_PROFILE)",
71
+ upload: true,
72
+ appPath: "$(AC_APP_PATH)"
73
+ )
74
+ end
75
+ ```
76
+
77
+ **Publish only (publishes the profile's current release candidate):**
78
+
79
+ ```ruby
80
+ lane :trigger_publish do
81
+ appcircle_publish(
82
+ personalAPIToken: "$(AC_PERSONAL_API_TOKEN)",
83
+ platform: "$(AC_PLATFORM)",
84
+ publishProfile: "$(AC_PUBLISH_PROFILE)",
85
+ publish: true
86
+ )
87
+ end
88
+ ```
89
+
90
+ **Upload and publish (uploads, marks it release candidate, then publishes):**
91
+
92
+ ```ruby
93
+ lane :upload_and_publish do
94
+ appcircle_publish(
95
+ personalAPIToken: "$(AC_PERSONAL_API_TOKEN)",
96
+ platform: "$(AC_PLATFORM)",
97
+ publishProfile: "$(AC_PUBLISH_PROFILE)",
98
+ upload: true,
99
+ publish: true,
100
+ appPath: "$(AC_APP_PATH)"
101
+ )
102
+ end
103
+ ```
104
+
105
+ - `personalAPIToken` / `personalAccessKey`: Provide exactly one to authenticate Appcircle services.
106
+ - `platform`: Target platform of the Publish profile — `ios` or `android`.
107
+ - `publishProfile`: Name of the Publish profile to target.
108
+ - `upload` (default `false`): Upload `appPath` as a new app version.
109
+ - `publish` (default `false`): Trigger the profile's publish flow.
110
+ - `appPath`: Path to the application file. Required when `upload` is `true`. For iOS use a `.ipa` file; for Android use a `.apk` or `.aab` file.
111
+
112
+ ### Self-Hosted Appcircle
113
+
114
+ If you run a self-hosted Appcircle installation, point the action to your own endpoints with the optional `authEndpoint` and `apiEndpoint` parameters. Both default to the Appcircle cloud, so existing cloud users do not need to set them.
115
+
116
+ - `authEndpoint` (optional): Authentication endpoint URL. Defaults to `https://auth.appcircle.io`.
117
+ - `apiEndpoint` (optional): API endpoint URL. Defaults to `https://api.appcircle.io`.
118
+
119
+ ```ruby
120
+ appcircle_publish(
121
+ personalAPIToken: "$(AC_PERSONAL_API_TOKEN)",
122
+ platform: "$(AC_PLATFORM)",
123
+ publishProfile: "$(AC_PUBLISH_PROFILE)",
124
+ publish: true,
125
+ authEndpoint: "https://auth.my-appcircle.example.com",
126
+ apiEndpoint: "https://api.my-appcircle.example.com"
127
+ )
128
+ ```
129
+
130
+ > **Self-signed or private CA certificates:** If your self-hosted Appcircle server uses a self-signed certificate (or one issued by a private/internal CA), requests will fail certificate validation. The plugin does not disable TLS verification. Trust the server's CA on the machine running Fastlane — add it to the system certificate store, or point the `SSL_CERT_FILE` environment variable at a PEM bundle that includes it.
131
+
132
+ ### Leveraging Environment Variables
133
+
134
+ Utilize environment variables seamlessly by substituting the parameters with $(VARIABLE_NAME) in your task inputs. The plugin automatically retrieves values from the specified environment variables within your pipeline.
135
+
136
+ If you would like to learn more about this plugin and how to utilize it in your projects, please [contact us](https://appcircle.io/contact?utm_source=fastlane&utm_medium=plugin&utm_campaign=publish)
137
+
138
+ ## Issues and Feedback
139
+
140
+ For any other issues and feedback about this plugin, please submit it to this repository.
141
+
142
+ ## Troubleshooting
143
+
144
+ If you have trouble using plugins, check out the [Plugins Troubleshooting](https://docs.fastlane.tools/plugins/plugins-troubleshooting/) guide.
145
+
146
+ ### Reference
147
+
148
+ For more detailed instructions and support, visit the [Appcircle Publish documentation](https://docs.appcircle.io/publish-to-stores-module).
@@ -0,0 +1,290 @@
1
+ require 'fastlane/action'
2
+ require 'net/http'
3
+ require 'uri'
4
+ require 'json'
5
+
6
+ require 'fastlane/action'
7
+ require_relative '../helper/appcircle_publish_helper'
8
+
9
+ require_relative '../helper/auth_service'
10
+ require_relative '../helper/upload_service'
11
+
12
+ module Fastlane
13
+ module Actions
14
+ class AppcirclePublishAction < Action
15
+ @@apiToken = nil
16
+ @@authEndpoint = "https://auth.appcircle.io"
17
+ @@apiEndpoint = "https://api.appcircle.io"
18
+
19
+ FLOW_STEP_STATUS = {
20
+ 0 => 'Success', 1 => 'Failed', 2 => 'Cancelled', 3 => 'Timeout',
21
+ 90 => 'Waiting', 91 => 'Running', 92 => 'Completing', 99 => 'Unknown',
22
+ 100 => 'Skipped', 200 => 'Not Started', 201 => 'Stopped',
23
+ 202 => 'In Progress', 203 => 'Awaiting Response'
24
+ }.freeze
25
+ TERMINAL_STEP_STATUSES = [0, 1, 2, 3, 100, 201].freeze
26
+ ACTIVE_STEP_STATUSES = [91, 92, 202].freeze
27
+
28
+ def self.run(params)
29
+ personalAPIToken = params[:personalAPIToken]
30
+ personalAccessKey = params[:personalAccessKey]
31
+ @@authEndpoint = params[:authEndpoint]
32
+ @@apiEndpoint = params[:apiEndpoint]
33
+ platform = params[:platform] && params[:platform].downcase
34
+ publishProfile = params[:publishProfile]
35
+ appPath = params[:appPath]
36
+ upload = params[:upload]
37
+ publish = params[:publish]
38
+
39
+ # --- Validation ---
40
+ if !upload && !publish
41
+ UI.user_error!("Nothing to do: set 'upload' and/or 'publish' to true.")
42
+ end
43
+
44
+ if personalAPIToken.nil? && personalAccessKey.nil?
45
+ UI.user_error!("Please provide either Personal API Token (personalAPIToken) or Personal Access Key (personalAccessKey) to authenticate connections to Appcircle services")
46
+ elsif !personalAPIToken.nil? && !personalAccessKey.nil?
47
+ UI.user_error!("Please provide only one authentication method: either Personal API Token (personalAPIToken) or Personal Access Key (personalAccessKey), not both")
48
+ end
49
+
50
+ if platform.nil? || (platform != "ios" && platform != "android")
51
+ UI.user_error!("Please provide a valid platform for the Publish profile: 'ios' or 'android'")
52
+ end
53
+
54
+ if publishProfile.nil?
55
+ UI.user_error!("Please provide the name of the Publish profile to target")
56
+ end
57
+
58
+ if upload
59
+ UI.user_error!("Please specify 'appPath' when 'upload' is true. For iOS use a .ipa file; for Android use a .apk or .aab file") if appPath.nil?
60
+ valid_extensions = ['.apk', '.aab', '.ipa']
61
+ file_extension = File.extname(appPath).downcase
62
+ unless valid_extensions.include?(file_extension)
63
+ raise "Invalid file extension: #{file_extension}. For Android, use .apk or .aab. For iOS, use .ipa."
64
+ end
65
+ end
66
+
67
+ # --- Auth + profile ---
68
+ if personalAPIToken.nil?
69
+ self.ac_login_with_pak(personalAccessKey)
70
+ else
71
+ self.ac_login_with_pat(personalAPIToken)
72
+ end
73
+
74
+ profileId = UploadService.get_publish_profile_id(auth_token: @@apiToken, platform: platform, profile_name: publishProfile, api_endpoint: @@apiEndpoint)
75
+
76
+ # Guard: never start a new publish if one is already running for the profile.
77
+ if publish
78
+ active = UploadService.get_active_publish_count_for_profile(auth_token: @@apiToken, publish_profile_id: profileId, api_endpoint: @@apiEndpoint)
79
+ if active > 0
80
+ UI.user_error!("A publish is already in progress for profile '#{publishProfile}'. Not starting a new one.")
81
+ end
82
+ end
83
+
84
+ appVersionId = nil
85
+
86
+ # --- Upload ---
87
+ if upload
88
+ response = UploadService.upload_artifact(token: @@apiToken, app: appPath, platform: platform, publish_profile_id: profileId, api_endpoint: @@apiEndpoint)
89
+ self.checkTaskStatus(response["taskId"])
90
+ appVersionId = UploadService.get_latest_app_version_id(auth_token: @@apiToken, platform: platform, publish_profile_id: profileId, api_endpoint: @@apiEndpoint)
91
+ UI.success("#{appPath} uploaded to the Appcircle Publish profile '#{publishProfile}' successfully")
92
+ end
93
+
94
+ # --- Publish ---
95
+ if publish
96
+ if upload && appVersionId
97
+ UploadService.mark_release_candidate(auth_token: @@apiToken, platform: platform, publish_profile_id: profileId, app_version_id: appVersionId, api_endpoint: @@apiEndpoint)
98
+ UI.message("Marked the uploaded version as release candidate.")
99
+ else
100
+ appVersionId = UploadService.get_release_candidate_version_id(auth_token: @@apiToken, platform: platform, publish_profile_id: profileId, api_endpoint: @@apiEndpoint)
101
+ end
102
+
103
+ publishId = UploadService.get_publish_id(auth_token: @@apiToken, platform: platform, publish_profile_id: profileId, app_version_id: appVersionId, api_endpoint: @@apiEndpoint)
104
+ UploadService.start_publish(auth_token: @@apiToken, platform: platform, publish_profile_id: profileId, publish_id: publishId, api_endpoint: @@apiEndpoint)
105
+ UI.success("Publish flow started for profile '#{publishProfile}'.")
106
+ success = self.poll_publish_status(platform, profileId, appVersionId)
107
+ UI.user_error!("Publish flow failed.") unless success
108
+ end
109
+ end
110
+
111
+ def self.ac_login_with_pat(accessToken)
112
+ user = AuthService.get_ac_token(pat: accessToken, auth_endpoint: @@authEndpoint)
113
+ UI.success("Login is successful.")
114
+ @@apiToken = user.accessToken
115
+ rescue StandardError => e
116
+ UI.error("Login failed: #{e.message}")
117
+ raise e
118
+ end
119
+
120
+ def self.ac_login_with_pak(personalAccessKey)
121
+ user = AuthService.get_ac_token_with_pak(personal_access_key: personalAccessKey, auth_endpoint: @@authEndpoint)
122
+ UI.success("Login is successful.")
123
+ @@apiToken = user.accessToken
124
+ rescue StandardError => e
125
+ UI.error("Login failed: #{e.message}")
126
+ raise e
127
+ end
128
+
129
+ def self.checkTaskStatus(taskId)
130
+ uri = URI.parse("#{@@apiEndpoint}/task/v1/tasks/#{taskId}")
131
+
132
+ response = self.send_request(uri, @@apiToken)
133
+ if response.kind_of?(Net::HTTPSuccess)
134
+ stateValue = JSON.parse(response.body)["stateValue"]
135
+ if stateValue == 1
136
+ sleep(1)
137
+ return checkTaskStatus(taskId)
138
+ end
139
+ if stateValue == 3
140
+ return true
141
+ else
142
+ taskStatus = { 0 => "Unknown", 1 => "Begin", 2 => "Canceled", 3 => 'Completed' }
143
+ UI.user_error!("#{taskId} id upload request failed with status #{taskStatus[stateValue]}.")
144
+ end
145
+ else
146
+ UI.user_error!("Upload failed with response code #{response.code} and message '#{response.message}'.")
147
+ end
148
+ end
149
+
150
+ # Poll the publish status until it terminates (status 0=success, 1=failed,
151
+ # anything else = running). Logs each step's start / await / terminal once,
152
+ # with status icons.
153
+ def self.poll_publish_status(platform, profileId, appVersionId, interval: 5, max_attempts: 240)
154
+ step_state = {}
155
+ max_attempts.times do
156
+ data = UploadService.get_publish_object(auth_token: @@apiToken, platform: platform, publish_profile_id: profileId, app_version_id: appVersionId, api_endpoint: @@apiEndpoint)
157
+ (data['steps'] || []).each do |step|
158
+ id = step['id'] || step['name']
159
+ next if id.nil?
160
+ st = (step_state[id] ||= {})
161
+ status = step['status']
162
+ if TERMINAL_STEP_STATUSES.include?(status) && !st[:done]
163
+ st[:done] = true
164
+ UI.message("#{step_icon(status)} #{step['name']} — #{step_status_name(status)}")
165
+ elsif status == 203 && !st[:awaiting] && !st[:done]
166
+ st[:awaiting] = true
167
+ UI.message("#{step_icon(status)} #{step['name']} — #{step_status_name(status)}")
168
+ elsif ACTIVE_STEP_STATUSES.include?(status) && !st[:started] && !st[:done]
169
+ st[:started] = true
170
+ UI.message("#{step_icon(status)} #{step['name']} — #{step_status_name(status)}")
171
+ end
172
+ end
173
+ status = data['status'].is_a?(Numeric) ? data['status'] : 99
174
+ return true if status == 0
175
+ return false if status == 1
176
+ sleep(interval)
177
+ end
178
+ UI.user_error!("Publish status polling timed out.")
179
+ end
180
+
181
+ def self.step_status_name(status)
182
+ FLOW_STEP_STATUS[status] || "Unknown (#{status})"
183
+ end
184
+
185
+ def self.step_icon(status)
186
+ case status
187
+ when 0 then '✅'
188
+ when 1 then '❌'
189
+ when 2 then '🚫'
190
+ when 3 then '⌛'
191
+ when 100 then '⏭️'
192
+ when 201 then '⏹️'
193
+ when 203 then '⏸️'
194
+ else '▶️'
195
+ end
196
+ end
197
+
198
+ def self.send_request(uri, access_token)
199
+ http = Net::HTTP.new(uri.host, uri.port)
200
+ http.use_ssl = (uri.scheme == "https")
201
+ request = Net::HTTP::Get.new(uri.request_uri)
202
+ request["Authorization"] = "Bearer #{access_token}"
203
+ http.request(request)
204
+ end
205
+
206
+ def self.description
207
+ "Upload a binary to an Appcircle Publish profile and/or trigger its publish flow"
208
+ end
209
+
210
+ def self.authors
211
+ ["Burak Yıldırım"]
212
+ end
213
+
214
+ def self.return_value
215
+ end
216
+
217
+ def self.details
218
+ "Uploads an application binary to an existing Appcircle Publish profile and/or triggers the profile's publish flow. Upload and publish are independent options: enable either or both. When both are enabled, the uploaded version is marked as release candidate and published; a new publish is never started if one is already in progress for the profile."
219
+ end
220
+
221
+ def self.available_options
222
+ [
223
+ FastlaneCore::ConfigItem.new(key: :personalAPIToken,
224
+ env_name: "AC_PERSONAL_API_TOKEN",
225
+ description: "Provide Personal API Token to authenticate Appcircle services (use either personalAPIToken or personalAccessKey)",
226
+ optional: true,
227
+ type: String),
228
+
229
+ FastlaneCore::ConfigItem.new(key: :personalAccessKey,
230
+ env_name: "AC_PERSONAL_ACCESS_KEY",
231
+ description: "Provide Personal Access Key to authenticate Appcircle services (use either personalAPIToken or personalAccessKey)",
232
+ optional: true,
233
+ type: String),
234
+
235
+ FastlaneCore::ConfigItem.new(key: :authEndpoint,
236
+ env_name: "AC_AUTH_ENDPOINT",
237
+ description: "Optional: Authentication endpoint URL for self-hosted Appcircle installations. Defaults to the Appcircle cloud",
238
+ optional: true,
239
+ default_value: "https://auth.appcircle.io",
240
+ type: String),
241
+
242
+ FastlaneCore::ConfigItem.new(key: :apiEndpoint,
243
+ env_name: "AC_API_ENDPOINT",
244
+ description: "Optional: API endpoint URL for self-hosted Appcircle installations. Defaults to the Appcircle cloud",
245
+ optional: true,
246
+ default_value: "https://api.appcircle.io",
247
+ type: String),
248
+
249
+ FastlaneCore::ConfigItem.new(key: :platform,
250
+ env_name: "AC_PLATFORM",
251
+ description: "Target platform of the Publish profile: 'ios' or 'android'",
252
+ optional: false,
253
+ type: String),
254
+
255
+ FastlaneCore::ConfigItem.new(key: :publishProfile,
256
+ env_name: "AC_PUBLISH_PROFILE",
257
+ description: "Name of the Publish profile to target. Profile names are unique per platform",
258
+ optional: false,
259
+ type: String),
260
+
261
+ FastlaneCore::ConfigItem.new(key: :upload,
262
+ env_name: "AC_UPLOAD",
263
+ description: "Upload the binary at 'appPath' as a new app version. At least one of 'upload' or 'publish' must be true",
264
+ optional: true,
265
+ default_value: false,
266
+ is_string: false,
267
+ type: Boolean),
268
+
269
+ FastlaneCore::ConfigItem.new(key: :publish,
270
+ env_name: "AC_PUBLISH",
271
+ description: "Trigger the profile's publish flow. When combined with upload, the uploaded version is marked release candidate and published; otherwise the profile's current release candidate is published",
272
+ optional: true,
273
+ default_value: false,
274
+ is_string: false,
275
+ type: Boolean),
276
+
277
+ FastlaneCore::ConfigItem.new(key: :appPath,
278
+ env_name: "AC_APP_PATH",
279
+ description: "Path to the application file. Required when 'upload' is true. For iOS use a .ipa file; for Android use a .apk or .aab file",
280
+ optional: true,
281
+ type: String)
282
+ ]
283
+ end
284
+
285
+ def self.is_supported?(platform)
286
+ true
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,16 @@
1
+ require 'fastlane_core/ui/ui'
2
+
3
+ module Fastlane
4
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
5
+
6
+ module Helper
7
+ class AppcirclePublishHelper
8
+ # class methods that you define here become available in your action
9
+ # as `Helper::AppcirclePublishHelper.your_method`
10
+ #
11
+ def self.show_message
12
+ UI.message("Hello from the appcircle_publish plugin helper!")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,78 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'cgi'
4
+ require 'json'
5
+
6
+ class UserResponse
7
+ attr_accessor :accessToken
8
+
9
+ def initialize(accessToken:)
10
+ @accessToken = accessToken
11
+ end
12
+ end
13
+
14
+ module AuthService
15
+ def self.get_ac_token(pat:, auth_endpoint: 'https://auth.appcircle.io')
16
+ endpoint_url = "#{auth_endpoint}/auth/v2/token"
17
+ uri = URI(endpoint_url)
18
+
19
+ # Create HTTP request
20
+ request = Net::HTTP::Post.new(uri)
21
+ request.content_type = 'application/x-www-form-urlencoded'
22
+ request['Accept'] = 'application/json'
23
+
24
+ # Encode parameters
25
+ params = { pat: pat }
26
+ request.body = URI.encode_www_form(params)
27
+
28
+ # Make the HTTP request
29
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
30
+ http.request(request)
31
+ end
32
+
33
+ # Check response
34
+ if response.kind_of?(Net::HTTPSuccess)
35
+ response_data = JSON.parse(response.body)
36
+
37
+ user = UserResponse.new(
38
+ accessToken: response_data['access_token']
39
+ )
40
+
41
+ return user
42
+ else
43
+ raise "HTTP Request failed (#{response.code} #{response.message})"
44
+ end
45
+ end
46
+
47
+ def self.get_ac_token_with_pak(personal_access_key:, auth_endpoint: 'https://auth.appcircle.io')
48
+ endpoint_url = "#{auth_endpoint}/auth/v1/token"
49
+ uri = URI(endpoint_url)
50
+
51
+ # Create HTTP request
52
+ request = Net::HTTP::Post.new(uri)
53
+ request.content_type = 'application/x-www-form-urlencoded'
54
+ request['Accept'] = 'application/json'
55
+
56
+ # Encode parameters
57
+ params = { 'personal-access-key' => personal_access_key }
58
+ request.body = URI.encode_www_form(params)
59
+
60
+ # Make the HTTP request
61
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
62
+ http.request(request)
63
+ end
64
+
65
+ # Check response
66
+ if response.kind_of?(Net::HTTPSuccess)
67
+ response_data = JSON.parse(response.body)
68
+
69
+ user = UserResponse.new(
70
+ accessToken: response_data['access_token']
71
+ )
72
+
73
+ return user
74
+ else
75
+ raise "HTTP Request failed (#{response.code} #{response.message})"
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,149 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'rest-client'
5
+
6
+ BASE_URL = "https://api.appcircle.io"
7
+
8
+ module UploadService
9
+ def self.put_with_retry(url, body, headers, max_retries: 5)
10
+ attempt = 0
11
+ delay = 1.0
12
+
13
+ begin
14
+ RestClient.put(url, body, headers)
15
+ rescue => e
16
+ status = e.respond_to?(:http_code) ? e.http_code : nil
17
+ retryable = status == 503 ||
18
+ e.is_a?(RestClient::ServerBrokeConnection) ||
19
+ e.is_a?(Errno::ECONNRESET) ||
20
+ (defined?(RestClient::Exceptions::OpenTimeout) && e.is_a?(RestClient::Exceptions::OpenTimeout)) ||
21
+ (defined?(RestClient::Exceptions::ReadTimeout) && e.is_a?(RestClient::Exceptions::ReadTimeout))
22
+
23
+ raise e if !retryable || attempt >= max_retries
24
+
25
+ attempt += 1
26
+ sleep(delay + rand(0.3)) # backoff + jitter (0-300ms)
27
+ delay *= 2
28
+ retry
29
+ end
30
+ end
31
+
32
+ # Resolve a publish profile id from its name for the given platform.
33
+ # Profile names are unique per (organization, platform), so the match is exact.
34
+ def self.get_publish_profile_id(auth_token:, platform:, profile_name:, api_endpoint: BASE_URL)
35
+ url = "#{api_endpoint}/publish/v2/profiles/#{platform}"
36
+ headers = { Authorization: "Bearer #{auth_token}", accept: 'application/json' }
37
+
38
+ profiles = JSON.parse(RestClient.get(url, headers).body)
39
+ profile = (profiles || []).find { |p| p['name'] == profile_name }
40
+ if profile.nil?
41
+ UI.user_error!("Publish profile '#{profile_name}' not found for platform '#{platform}'.")
42
+ end
43
+ profile['id']
44
+ rescue RestClient::ExceptionWithResponse => e
45
+ raise e
46
+ end
47
+
48
+ def self.upload_artifact(token:, app:, platform:, publish_profile_id:, api_endpoint: BASE_URL)
49
+ file_path = app
50
+ file_name = File.basename(file_path)
51
+ file_size = File.size(file_path)
52
+ auth_header = { Authorization: "Bearer #{token}", accept: 'application/json' }
53
+ # Profile listing is v2, but the signed-URL upload/commit actions live on v1.
54
+ base = "#{api_endpoint}/publish/v1/profiles/#{platform}/#{publish_profile_id}/app-versions"
55
+
56
+ begin
57
+ info_uri = URI(base)
58
+ info_uri.query = URI.encode_www_form({ action: 'uploadInformation', fileName: file_name, fileSize: file_size })
59
+ upload_info = JSON.parse(RestClient.get(info_uri.to_s, auth_header).body)
60
+ file_id = upload_info['fileId']
61
+ upload_url = upload_info['uploadUrl']
62
+ configuration = upload_info['configuration']
63
+ http_method = (configuration && configuration['httpMethod']) ? configuration['httpMethod'].to_s.upcase : 'PUT'
64
+
65
+ if http_method == 'POST'
66
+ sign_parameters = configuration['signParameters'] || {}
67
+ payload = {}
68
+ sign_parameters.each { |key, value| payload[key] = value }
69
+ payload['file'] = File.new(file_path, 'rb') # the 'file' field MUST be last
70
+ RestClient.post(upload_url, payload)
71
+ else
72
+ put_with_retry(upload_url, File.binread(file_path), { content_type: 'application/octet-stream' })
73
+ end
74
+
75
+ commit_uri = URI(base)
76
+ commit_uri.query = URI.encode_www_form({ action: 'commitFileUpload' })
77
+
78
+ commit_payload = { fileId: file_id, fileName: file_name }.to_json
79
+ commit_headers = { Authorization: "Bearer #{token}", content_type: :json, accept: 'application/json' }
80
+ JSON.parse(RestClient.post(commit_uri.to_s, commit_payload, commit_headers).body)
81
+ rescue RestClient::ExceptionWithResponse => e
82
+ raise e
83
+ rescue StandardError => e
84
+ raise e
85
+ end
86
+ end
87
+
88
+ def self.auth_headers(token)
89
+ { Authorization: "Bearer #{token}", accept: 'application/json' }
90
+ end
91
+
92
+ def self.get_app_versions(auth_token:, platform:, publish_profile_id:, api_endpoint: BASE_URL)
93
+ url = "#{api_endpoint}/publish/v2/profiles/#{platform}/#{publish_profile_id}/app-versions"
94
+ data = JSON.parse(RestClient.get(url, auth_headers(auth_token)).body)
95
+ data.is_a?(Array) ? data : (data['data'] || [])
96
+ end
97
+
98
+ # The most recently created app version (used after an upload).
99
+ def self.get_latest_app_version_id(auth_token:, platform:, publish_profile_id:, api_endpoint: BASE_URL)
100
+ versions = get_app_versions(auth_token: auth_token, platform: platform, publish_profile_id: publish_profile_id, api_endpoint: api_endpoint)
101
+ UI.user_error!("No app versions found on the publish profile after upload.") if versions.empty?
102
+ versions[0]['id']
103
+ end
104
+
105
+ # The profile's current release candidate (published in publish-only mode).
106
+ def self.get_release_candidate_version_id(auth_token:, platform:, publish_profile_id:, api_endpoint: BASE_URL)
107
+ versions = get_app_versions(auth_token: auth_token, platform: platform, publish_profile_id: publish_profile_id, api_endpoint: api_endpoint)
108
+ rc = versions.find { |v| v['releaseCandidate'] == true }
109
+ UI.user_error!("No release candidate app version found on the publish profile. Mark a version as release candidate (or enable upload) before publishing.") if rc.nil?
110
+ rc['id']
111
+ end
112
+
113
+ def self.mark_release_candidate(auth_token:, platform:, publish_profile_id:, app_version_id:, api_endpoint: BASE_URL)
114
+ uri = URI("#{api_endpoint}/publish/v2/profiles/#{platform}/#{publish_profile_id}/app-versions/#{app_version_id}")
115
+ uri.query = URI.encode_www_form({ action: 'releaseCandidate' })
116
+ headers = { Authorization: "Bearer #{auth_token}", content_type: :json, accept: 'application/json' }
117
+ RestClient.patch(uri.to_s, { ReleaseCandidate: true }.to_json, headers)
118
+ end
119
+
120
+ # In-progress publishes for the given profile (scope: target profile).
121
+ def self.get_active_publish_count_for_profile(auth_token:, publish_profile_id:, api_endpoint: BASE_URL)
122
+ url = "#{api_endpoint}/build/v1/queue/my-dashboard?page=1&size=1000"
123
+ data = JSON.parse(RestClient.get(url, auth_headers(auth_token)).body)
124
+ items = data['data'] || []
125
+ items.count { |p| p['publishId'] && p['profileId'] == publish_profile_id }
126
+ end
127
+
128
+ def self.get_publish_id(auth_token:, platform:, publish_profile_id:, app_version_id:, api_endpoint: BASE_URL)
129
+ url = "#{api_endpoint}/publish/v2/profiles/#{platform}/#{publish_profile_id}/app-versions/#{app_version_id}/publish"
130
+ data = JSON.parse(RestClient.get(url, auth_headers(auth_token)).body)
131
+ steps = data['steps'] || []
132
+ publish_id = steps[0] && steps[0]['publishId']
133
+ UI.user_error!("No publish flow steps found for the app version. Configure a publish flow on the profile first.") if publish_id.nil?
134
+ publish_id
135
+ end
136
+
137
+ def self.start_publish(auth_token:, platform:, publish_profile_id:, publish_id:, api_endpoint: BASE_URL)
138
+ uri = URI("#{api_endpoint}/publish/v2/profiles/#{platform}/#{publish_profile_id}/publish/#{publish_id}")
139
+ uri.query = URI.encode_www_form({ action: 'restart' })
140
+ headers = { Authorization: "Bearer #{auth_token}", content_type: :json, accept: 'application/json' }
141
+ RestClient.post(uri.to_s, "{}", headers)
142
+ end
143
+
144
+ # Single fetch of the publish object (status + steps). Polled by the action.
145
+ def self.get_publish_object(auth_token:, platform:, publish_profile_id:, app_version_id:, api_endpoint: BASE_URL)
146
+ url = "#{api_endpoint}/publish/v1/profiles/#{platform}/#{publish_profile_id}/app-versions/#{app_version_id}/publish"
147
+ JSON.parse(RestClient.get(url, auth_headers(auth_token)).body)
148
+ end
149
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module AppcirclePublish
3
+ VERSION = "0.1.0.beta.1"
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ require 'fastlane/plugin/appcircle_publish/version'
2
+
3
+ module Fastlane
4
+ module AppcirclePublish
5
+ # Return all .rb files inside the "actions" and "helper" directory
6
+ def self.all_classes
7
+ Dir[File.expand_path('**/{actions,helper}/*.rb', File.dirname(__FILE__))]
8
+ end
9
+ end
10
+ end
11
+
12
+ # By default we want to import all available actions and helpers
13
+ # A plugin can contain any number of actions and plugins
14
+ Fastlane::AppcirclePublish.all_classes.each do |current|
15
+ require current
16
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fastlane-plugin-appcircle_publish
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.beta.1
5
+ platform: ruby
6
+ authors:
7
+ - appcircleio
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rest-client
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description:
28
+ email: cloud@appcircle.io
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE
34
+ - README.md
35
+ - lib/fastlane/plugin/appcircle_publish.rb
36
+ - lib/fastlane/plugin/appcircle_publish/actions/appcircle_publish_action.rb
37
+ - lib/fastlane/plugin/appcircle_publish/helper/appcircle_publish_helper.rb
38
+ - lib/fastlane/plugin/appcircle_publish/helper/auth_service.rb
39
+ - lib/fastlane/plugin/appcircle_publish/helper/upload_service.rb
40
+ - lib/fastlane/plugin/appcircle_publish/version.rb
41
+ homepage: https://github.com/appcircleio/fastlane-plugin-appcircle_publish
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ rubygems_mfa_required: 'true'
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.6'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">"
58
+ - !ruby/object:Gem::Version
59
+ version: 1.3.1
60
+ requirements: []
61
+ rubygems_version: 3.3.27
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: Upload an application binary to an Appcircle Publish profile
65
+ test_files: []