fastlane-plugin-appcircle_testing_distribution 0.4.1 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ceb1940fa8c0d58e985bded6be73f61f64afb0599e852c52431811159af01212
4
- data.tar.gz: 130705717aba452336764a57fe29a5c14c29b648e8d528e1ce3edae406e66359
3
+ metadata.gz: 213b75b8d9c150a9206153fa4bb835277992aac5ba70544ac201337a53eae712
4
+ data.tar.gz: 6dc6d2d960d77b3b7db6976b58aeefc17e0cfb3164bc52d137779a2f7765ae36
5
5
  SHA512:
6
- metadata.gz: c1537e66400a4f57df3553ce8ef8fcadc89bee630557aa2eed668e7862e32327f391e0e6c280a7b01d7b9568ce3e3677608d771f089a4eb9aeb79f9ee0792405
7
- data.tar.gz: f39fbbe473f79aceeac915a63fdbe9a638eb56a91172b8c7403d34367d95883a31e32f78cafb4fdb9ef3526108b534c4efb58a449f740ea906264aed08e4fb4b
6
+ metadata.gz: 3ec4a590c5418d685a2f2c7fa9b2618bf09d91d3a6bcc26252d1ec6e164b4d39a756065f0b35d886f79e75e04c0ea40a797c9de9c373ab35c9b3e3b920fdbabe
7
+ data.tar.gz: 0e980015ac5ee5f865da4262a1d7bd090f2c2d5cb8ce4e761ff8e8e718d61ea8822bb8c47295f1f45342d28bfaa2204923dcea78853b081a013ab21fca6aece7
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2024 Guven Karanfil <guven.karanfil@smartface.io>
3
+ Copyright (c) 2024 Appcircle, Inc. <info@appcircle.io>
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ## Appcircle Testing Distribution
1
+ # Appcircle Testing Distribution
2
2
 
3
3
  [![fastlane Plugin Badge](https://rawcdn.githack.com/fastlane/fastlane/master/fastlane/assets/plugin-badge.svg)](https://rubygems.org/gems/fastlane-plugin-appcircle_testing_distribution)
4
4
 
@@ -13,7 +13,7 @@ Testing distribution is the process of distributing test builds to designated te
13
13
  ## Benefits of Using Testing Distribution
14
14
 
15
15
  1. **Simplified Binary Distribution**.
16
- - **Skip Traditional Stores:** Share .xcarchive .IPA, APK, AAB, Zip, files directly, avoiding the need to use App Store TestFlight or Google Play Internal Testing.
16
+ - **Skip Traditional Stores:** Share IPA, APK, AAB files directly, avoiding the need to use App Store TestFlight or Google Play Internal Testing.
17
17
  2. **Streamlined Workflow:**
18
18
  - **Automated Processes:** Platforms like Appcircle automate the distribution process, saving time and reducing manual effort.
19
19
  - **Seamless Integration:** Integrates smoothly with existing DevOps pipelines, enabling efficient build and distribution workflows.
@@ -38,24 +38,11 @@ Testing distribution is the process of distributing test builds to designated te
38
38
 
39
39
  Overall, using testing distribution in mobile DevOps significantly enhances the efficiency, security, and effectiveness of the software development process, leading to better products and faster delivery times.
40
40
 
41
- ## System Requirements
41
+ <!-- ## Testing Distribution
42
42
 
43
- **Compatible Agents:**
43
+ In order to share your builds with testers, you can create testing distribution profiles and assign testing groups to the profiles.
44
44
 
45
- - macOS 14.2, 14.5
46
-
47
- **Supported Version:**
48
-
49
- - Fastlane 2.222.0
50
- - Ruby 3.2.2
51
-
52
- Note: Currently, plugins are only compatible to use with **Appcircle Cloud**. **Self-hosted** support will be available in future releases.
53
-
54
- ## Testing Distribution
55
-
56
- In order to share your builds with testers, you can create distribution profiles and assign testing groups to the distribution profiles.
57
-
58
- ![Distribution Profile](<https://cdn.appcircle.io/docs/assets/image%20(152).png>)
45
+ ![Testing Distribution Profile](<https://cdn.appcircle.io/docs/assets/image%20(152).png>)
59
46
 
60
47
  ## Generating/Managing the Personal API Tokens
61
48
 
@@ -65,11 +52,11 @@ To generate a Personal API Token, follow these steps:
65
52
  2. You'll find the Personal API Token section in the top right corner.
66
53
  3. Press the "Generate Token" button to generate your first token.
67
54
 
68
- ![Token Generation](<https://cdn.appcircle.io/docs/assets/image%20(164).png>)
55
+ ![Token Generation](<https://cdn.appcircle.io/docs/assets/image%20(164).png>) -->
69
56
 
70
57
  ## Getting Started with the Extension: Usage Guide
71
58
 
72
- To share your builds with testers, you can create distribution profiles and assign testing groups to these profiles.
59
+ To share your builds with testers, you can create testing distribution profiles and assign testing groups to these profiles.
73
60
 
74
61
  This project is a [_fastlane_](https://github.com/fastlane/fastlane) plugin. To get started with `fastlane-plugin-appcircle_testing_distribution`, add it to your project by running:
75
62
 
@@ -77,31 +64,59 @@ This project is a [_fastlane_](https://github.com/fastlane/fastlane) plugin. To
77
64
  fastlane add_plugin appcircle_testing_distribution
78
65
  ```
79
66
 
80
- ```yml
67
+ ```ruby
81
68
  appcircle_testing_distribution(
82
- personalAPIToken: "$(AC_PERSONAL_API_TOKEN)",
83
- profileName: "$(AC_PROFILE_NAME)",
84
- createProfileIfNotExists: Boolean,
85
- appPath: "$(AC_APP_PATH)",
86
- message: "$(AC_MESSAGE)",
69
+ personalAPIToken: ENV["AC_PERSONAL_API_TOKEN"],
70
+ personalAccessKey: ENV["AC_PERSONAL_ACCESS_KEY"],
71
+ subOrganizationName: ENV["AC_SUB_ORGANIZATION_NAME"],
72
+ profileName: ENV["AC_PROFILE_NAME"],
73
+ createProfileIfNotExists: ENV["AC_CREATE_PROFILE_IF_NOT_EXISTS"] == "true",
74
+ profileCreationSettings: {
75
+ authType: ENV["AC_PROFILE_AUTH_TYPE"],
76
+ username: ENV["AC_PROFILE_USERNAME"],
77
+ password: ENV["AC_PROFILE_PASSWORD"],
78
+ testingGroupNames: ENV["AC_PROFILE_TESTING_GROUP_NAMES"]
79
+ },
80
+ appPath: ENV["AC_APP_PATH"],
81
+ message: ENV["AC_MESSAGE"]
87
82
  )
88
83
  ```
89
84
 
90
- - `personalAPIToken`: The Appcircle Personal API token is utilized to authenticate and secure access to Appcircle services, ensuring that only authorized users can perform actions within the platform.
91
- - `profileName`: Specifies the profile that will be used for uploading the app.
92
- - `createProfileIfNotExists`: Ensures that a user profile is automatically created if it does not already exist; if the profile name already exists, the app will be uploaded to that existing profile instead.
93
- - `appPath`: Indicates the file path to the application that will be uploaded to Appcircle Testing Distribution Profile.
94
- - `message`: Your message to testers, ensuring they receive important updates and information regarding the application.
85
+ ### Authentication
95
86
 
96
- ### Leveraging Environment Variables
87
+ Provide **either** `personalAPIToken` **or** `personalAccessKey` — not both. If neither is provided, or both are provided at the same time, the plugin fails fast with a descriptive error.
97
88
 
98
- Utilize environment variables seamlessly by substituting the parameters with `$(VARIABLE_NAME)` in your task inputs. The extension automatically retrieves values from the specified environment variables within your pipeline.
89
+ - `personalAPIToken`: The Appcircle Personal API token used to authenticate and authorize access to Appcircle services within this plugin.
90
+ - `personalAccessKey`: Alternative authentication method using a Personal Access Key. Use this if your organization provisions access keys instead of API tokens.
99
91
 
100
- **Ensure that this action is added after build steps have been completed.**
92
+ > **Note:** `subOrganizationName` is currently supported only when authenticating with `personalAPIToken`. When used together with `personalAccessKey`, the sub-organization switch is skipped with a warning.
101
93
 
102
- **If multiple workflows start simultaneously, the order in which versions are shared in the Testing Distribution is determined by the execution order of the publish step. The version that completes its build and triggers the publish plugin first will be shared first, followed by the others in sequence.**
94
+ ### Other parameters
103
95
 
104
- Efficiently distribute test binaries or beta versions using Appcircle, featuring seamless IPA and APK distribution capabilities. Streamline your testing process with our versatile tool designed to optimize your distribution workflow. If you need support or more information, please [contact us](https://appcircle.io/contact?utm_source=fastlane&utm_medium=plugin&utm_campaign=testing_distribution)
96
+ - `subOrganizationName` (optional): Required when the Root Organization's `personalAPIToken` is used, and you want to create the profile under a sub-organization. In this case, provide the name of the sub-organization in this field. If you directly used the sub-organization's `personalAPIToken`, this parameter is not needed.
97
+ - `profileName`: Specifies the profile that will be used for uploading the app.
98
+ - `createProfileIfNotExists` (optional): Ensures that a testing distribution profile is automatically created if it does not already exist; if the profile name already exists, the app will be uploaded to that existing profile instead.
99
+ - `profileCreationSettings` (optional): If `createProfileIfNotExists` is `true` and a new profile being created, the profile will be configured with these settings.
100
+ - `authType`: Authentication type of the profile. `none`: None, `static`: Static Username and Password, `ldap`: LDAP Login, `sso`: SSO Login.
101
+ - `username`: The username for the profile if authentication type set to `static` (Static Username and Password).
102
+ - `password`: The password for the profile if authentication type set to `static` (Static Username and Password).
103
+ - `testingGroupNames`: Uploaded versions will be automatically shared with these testing groups. Example format: `group1, group2, group3`.
104
+ - `appPath`: Indicates the file path to the application package that will be uploaded to Appcircle Testing Distribution Profile.
105
+ - `message`: Your message to testers, ensuring they receive important updates and information regarding the application.
106
+
107
+ ## Further Details
108
+
109
+ For more information please refer to the documentation.
110
+
111
+ - [Setting Up Appcircle Testing Distribution Plugin](https://docs.appcircle.io/marketplace/fastlane/testing-distribution)
112
+ - [Discover Action](https://docs.appcircle.io/marketplace/fastlane/testing-distribution#discover-action)
113
+ - [System Requirements](https://docs.appcircle.io/marketplace/fastlane/testing-distribution#system-requirements)
114
+ - [User Permission Requirements](https://docs.appcircle.io/marketplace/fastlane/testing-distribution#user-permission-requirements)
115
+ - [How to Add the Appcircle Distribute Action to Your Pipeline](https://docs.appcircle.io/marketplace/fastlane/testing-distribution#how-to-add-the-appcircle-distribute-action-to-your-pipeline)
116
+ - [CLI Usage](https://docs.appcircle.io/marketplace/fastlane/testing-distribution#cli-usage)
117
+ - [Distributing to Sub-Organizations](https://docs.appcircle.io/marketplace/fastlane/testing-distribution#distributing-to-sub-organizations)
118
+ - [Leveraging Environment Variables](https://docs.appcircle.io/marketplace/fastlane/testing-distribution#leveraging-environment-variables)
119
+ - [References](https://docs.appcircle.io/marketplace/fastlane/testing-distribution#references)
105
120
 
106
121
  ## Issues and Feedback
107
122
 
@@ -110,9 +125,3 @@ For any other issues and feedback about this plugin, please submit it to this re
110
125
  ## Troubleshooting
111
126
 
112
127
  If you have trouble using plugins, check out the [Plugins Troubleshooting](https://docs.fastlane.tools/plugins/plugins-troubleshooting/) guide.
113
-
114
- ## Reference
115
-
116
- - For details on generating an Appcircle Personal API Token, visit [Generating/Managing Personal API Tokens](https://docs.appcircle.io/appcircle-api/api-authentication#generatingmanaging-the-personal-api-tokens?utm_source=fastlane&utm_medium=plugin&utm_campaign=testing_distribution)
117
-
118
- - To create or learn more about Appcircle testing and distribution profiles, please refer to [Creating or Selecting a Distribution Profile](https://docs.appcircle.io/distribute/create-or-select-a-distribution-profile?utm_source=fastlane&utm_medium=plugin&utm_campaign=testing_distribution)
@@ -10,74 +10,145 @@ require_relative '../helper/TDUploadService'
10
10
  module Fastlane
11
11
  module Actions
12
12
  class AppcircleTestingDistributionAction < Action
13
+ VALID_EXTENSIONS = ['.apk', '.aab', '.ipa']
14
+ AUTH_TYPE_MAPPING = {
15
+ 'none' => 1, # None
16
+ 'static' => 3, # Static Username and Password
17
+ 'ldap' => 4, # LDAP Login
18
+ 'sso' => 5 # SSO Login
19
+ }
20
+
13
21
  def self.run(params)
14
22
  personalAPIToken = params[:personalAPIToken]
15
23
  personalAccessKey = params[:personalAccessKey]
24
+ subOrganizationName = params[:subOrganizationName]
16
25
  profileName = params[:profileName]
26
+ createProfileIfNotExists = params[:createProfileIfNotExists] || false
27
+ #
28
+ profileCreationSettings = params[:profileCreationSettings]
29
+ profileAuthType = profileCreationSettings&.dig(:authType)
30
+ profileUsername = profileCreationSettings&.dig(:username)
31
+ profilePassword = profileCreationSettings&.dig(:password)
32
+ profileTestingGroupNames= profileCreationSettings&.dig(:testingGroupNames)
33
+ #
17
34
  appPath = params[:appPath]
18
35
  message = params[:message]
19
- createProfileIfNotExists = params[:createProfileIfNotExists]
20
36
 
21
- valid_extensions = ['.apk', '.aab', '.ipa', '.zip']
22
-
23
- file_extension = File.extname(appPath).downcase
24
- unless valid_extensions.include?(file_extension)
25
- UI.user_error!("Invalid file extension: #{file_extension}. For Android, use .apk or .aab. For iOS, use .ipa or .zip(.xcarchive).")
26
- end
37
+ profileAuthType = AUTH_TYPE_MAPPING[profileAuthType] # map input to API values
27
38
 
39
+ # Validate auth input (either-or, not both, not none)
28
40
  if personalAPIToken.nil? && personalAccessKey.nil?
29
- UI.user_error!("Either Personal API Token or Personal Access Key is required to authenticate connections to Appcircle services. Please provide a valid access token or access key")
41
+ UI.user_error!("Either Personal API Token or Personal Access Key is required to authenticate connections to Appcircle services. Please provide a valid access token or access key.")
30
42
  elsif !personalAPIToken.nil? && !personalAccessKey.nil?
31
- UI.user_error!("Personal API Token and Personal Access Key cannot be used together. Please provide only one authentication method")
32
- elsif profileName.nil?
33
- UI.user_error!("Distribution profile name is required to distribute applications. Please provide a distribution profile name")
34
- elsif appPath.nil?
35
- UI.user_error!("Application file path is required to distribute applications. Please provide a valid application file path")
36
- elsif message.nil?
37
- UI.user_error!("Message field is required. Please provide a valid message")
43
+ UI.user_error!("Personal API Token and Personal Access Key cannot be used together. Please provide only one authentication method.")
38
44
  end
39
45
 
46
+ # Auth
47
+ authToken = self.ac_login(personal_api_token: personalAPIToken,
48
+ personal_access_key: personalAccessKey,
49
+ sub_organization_name: subOrganizationName)
40
50
 
41
- authToken = self.ac_login(personalAPIToken, personalAccessKey)
51
+ # Get or create profile
52
+ profileId = self.ac_get_or_create_profile(authToken, profileName, createProfileIfNotExists, profileCreationSettings, profileAuthType, profileUsername, profilePassword, profileTestingGroupNames)
42
53
 
43
- profileId = TDUploadService.get_profile_id(authToken, profileName, createProfileIfNotExists)
44
- self.ac_upload(authToken, appPath, profileId, message)
54
+ # Upload package
55
+ self.ac_upload(authToken, appPath, profileId, profileName, message)
45
56
  end
46
57
 
47
- def self.ac_login(personalAPIToken, personalAccessKey)
58
+ def self.ac_login(personal_api_token:, personal_access_key:, sub_organization_name:)
48
59
  begin
49
- if personalAccessKey
50
- user = TDAuthService.get_ac_token_with_personal_access_key(personal_access_key: personalAccessKey)
60
+ token = ''
61
+
62
+ if personal_access_key
63
+ user = TDAuthService.get_ac_token_with_personal_access_key(personal_access_key: personal_access_key)
51
64
  else
52
- user = TDAuthService.get_ac_token(pat: personalAPIToken)
65
+ user = TDAuthService.get_ac_token(pat: personal_api_token)
53
66
  end
54
67
  UI.success("Login is successful.")
55
- return user.accessToken
68
+ token = user.accessToken
69
+
70
+ if sub_organization_name
71
+ if personal_access_key
72
+ UI.important("Warning: subOrganizationName is currently only supported with personalAPIToken auth. Ignoring sub-organization switch for Personal Access Key login.")
73
+ else
74
+ organization_id = TDAuthService.get_organization_id(access_token: token, name: sub_organization_name)
75
+ user = TDAuthService.get_ac_token(pat: personal_api_token, sub_organization_id: organization_id)
76
+ UI.message("Switched to sub-organization: #{sub_organization_name}")
77
+ token = user.accessToken
78
+ end
79
+ end
80
+
81
+ return token
82
+
56
83
  rescue => e
57
- UI.user_error!("Login failed: #{e.message}")
84
+ UI.user_error!("Login failed: \"#{e.message}\".")
58
85
  end
59
86
  end
60
-
87
+
88
+ def self.ac_get_or_create_profile(authToken, profileName, createProfileIfNotExists, profileCreationSettings, profileAuthType, profileUsername, profilePassword, profileTestingGroupNames)
89
+ begin
90
+ profileId = TDUploadService.get_profile_id(authToken, profileName)
91
+
92
+ if profileId
93
+ UI.message("Profile '#{profileName}' found with ID: #{profileId}.")
94
+ UI.important("Warning: Profile '#{profileName}' already exists, so the provided profile creation settings will be ignored. To update the profile settings, please use the Appcircle web interface.") if profileCreationSettings
95
+
96
+ elsif profileId.nil? && !createProfileIfNotExists
97
+ UI.user_error!("Error: Profile '#{profileName}' not found. The option 'createProfileIfNotExists' is set to false, so a new profile was not created. To automatically create a new profile when it doesn't exist, set 'createProfileIfNotExists' to true.")
98
+ elsif profileId.nil? && createProfileIfNotExists
99
+ UI.message("Profile '#{profileName}' not found. Creating the new profile...")
100
+ profileId = TDUploadService.create_profile(authToken, profileName, profileAuthType, profileUsername, profilePassword, profileTestingGroupNames)
101
+ end
102
+
103
+ return profileId
104
+
105
+ rescue => e
106
+ UI.user_error!("Couldn't get the profile: \"#{e.message}\".")
107
+ end
108
+ end
109
+
110
+ def self.ac_upload(token, appPath, profileID, profileName, message)
111
+ begin
112
+ UI.message("Upload started.")
113
+ response = TDUploadService.upload_artifact(token: token, message: message, app: appPath, dist_profile_id: profileID)
114
+ result = self.checkTaskStatus(token, response['taskId'])
115
+
116
+ if result
117
+ UI.success("#{appPath} uploaded to profile '#{profileName}' successfully 🎉")
118
+ end
119
+ rescue => e
120
+ status_code = e.respond_to?(:response) && e.response ? e.response.code : 'unknown'
121
+ UI.user_error!("Upload failed with status code '#{status_code}', with message \"#{e.message}\".")
122
+ end
123
+ end
124
+
61
125
  def self.checkTaskStatus(authToken, taskId)
62
126
  uri = URI.parse("https://api.appcircle.io/task/v1/tasks/#{taskId}")
63
- timeout = 1
64
127
 
65
- response = self.send_request(uri, authToken)
66
- if response.is_a?(Net::HTTPSuccess)
67
- stateValue = JSON.parse(response.body)["stateValue"]
68
-
69
- if stateValue == 1
70
- sleep(1)
71
- return checkTaskStatus(authToken, taskId)
72
- end
73
- if stateValue == 3
74
- return true
128
+ check_interval = 1
129
+ # timeout = 2 * 60 * 60 # 2 hours in seconds
130
+ # start_time = Time.now
131
+
132
+ loop do
133
+ response = self.send_request(uri, authToken)
134
+ if response.is_a?(Net::HTTPSuccess)
135
+ stateValue = JSON.parse(response.body)["stateValue"]
136
+
137
+ if stateValue == 1
138
+ sleep(check_interval)
139
+ elsif stateValue == 3
140
+ return true
141
+ else
142
+ UI.error("Task Id #{taskId} failed with state value #{stateValue}.")
143
+ UI.user_error!("Upload could not be completed successfully.")
144
+ end
75
145
  else
76
- UI.error("Task Id #{taskId} failed with state value #{stateValue}")
77
- raise "Upload could not completed successfully"
146
+ UI.user_error!("Upload failed with response code #{response.code} and message '#{response.message}'.")
78
147
  end
79
- else
80
- raise "Upload failed with response code #{response.code} and message '#{response.message}'"
148
+
149
+ # if Time.now - start_time > timeout
150
+ # UI.user_error!("Task Id #{taskId} timed out after 2 hours.")
151
+ # end
81
152
  end
82
153
  end
83
154
 
@@ -89,20 +160,6 @@ module Fastlane
89
160
  http.request(request)
90
161
  end
91
162
 
92
- def self.ac_upload(token, appPath, profileID, message)
93
- begin
94
- response = TDUploadService.upload_artifact(token: token, message: message, app: appPath, dist_profile_id: profileID)
95
- result = self.checkTaskStatus(token, response['taskId'])
96
-
97
- if result
98
- UI.success("#{appPath} Uploaded to profile id #{profileID} successfully 🎉")
99
- end
100
- rescue => e
101
- status_code = e.respond_to?(:response) && e.response ? e.response.code : 'unknown'
102
- UI.user_error!("Upload failed with status code #{status_code}, with message '#{e.message}'")
103
- end
104
- end
105
-
106
163
  def self.description
107
164
  "Efficiently distribute application builds to users or testing groups using Appcircle's robust platform."
108
165
  end
@@ -117,7 +174,7 @@ module Fastlane
117
174
 
118
175
  def self.details
119
176
  # Optional:
120
- "Appcircle simplifies the distribution of builds to test teams with an extensive platform for managing and tracking applications, versions, testers, and teams. Appcircle integrates with enterprise authentication mechanisms such as LDAP and SSO, ensuring secure distribution of testing packages. Learn more about Appcircle testing distribution"
177
+ "Appcircle simplifies the distribution of builds to test teams with an extensive platform for managing and tracking applications, versions, testers, and teams. Appcircle integrates with enterprise authentication mechanisms such as LDAP and SSO, ensuring secure distribution of testing packages. Learn more about Appcircle testing distribution."
121
178
  end
122
179
 
123
180
  def self.available_options
@@ -127,36 +184,80 @@ module Fastlane
127
184
  description: "Provide Personal API Token to authenticate connections to Appcircle services (alternative to personalAccessKey)",
128
185
  optional: true,
129
186
  type: String),
130
-
187
+
131
188
  FastlaneCore::ConfigItem.new(key: :personalAccessKey,
132
189
  env_name: "AC_PERSONAL_ACCESS_KEY",
133
190
  description: "Provide Personal Access Key to authenticate connections to Appcircle services (alternative to personalAPIToken)",
134
191
  optional: true,
135
192
  type: String),
136
-
193
+
194
+ FastlaneCore::ConfigItem.new(key: :subOrganizationName,
195
+ env_name: "AC_SUB_ORGANIZATION_NAME",
196
+ description: "Optional: Sub-organization name for app distribution. Profiles will be created under root organization if not provided",
197
+ optional: true,
198
+ type: String),
199
+
137
200
  FastlaneCore::ConfigItem.new(key: :profileName,
138
201
  env_name: "AC_PROFILE_NAME",
139
- description: "Enter the profile name of the Appcircle distribution profile. This name uniquely identifies the profile under which your applications will be distributed",
202
+ description: "Enter the profile name of the Appcircle testing distribution profile. This name uniquely identifies the profile under which your applications will be distributed",
140
203
  optional: false,
141
- type: String),
142
-
204
+ type: String,
205
+ verify_block: proc do |value|
206
+ UI.user_error!("Profile name cannot be empty. Please provide a testing distribution profile name.") unless value && !value.empty?
207
+ end),
208
+
143
209
  FastlaneCore::ConfigItem.new(key: :createProfileIfNotExists,
144
210
  env_name: "AC_CREATE_PROFILE_IF_NOT_EXISTS",
145
- description: "If the profile does not exist, create a new profile with the given name",
211
+ description: "Optional: If the profile does not exist, create a new profile with the given name",
146
212
  optional: true,
147
213
  type: Boolean),
148
214
 
215
+ FastlaneCore::ConfigItem.new(key: :profileCreationSettings,
216
+ description: "Optional: Profile creation settings for the testing distribution profile",
217
+ optional: true,
218
+ type: Hash,
219
+ verify_block: proc do |value|
220
+ # Parse and Validate
221
+ if value[:authType] && !value[:authType].empty?
222
+ UI.user_error!("Invalid authType: '#{value[:authType]}'. Options: 'none' (None), 'static' (Static Username and Password), 'ldap' (LDAP Login), 'sso' (SSO Login).") unless AUTH_TYPE_MAPPING.key?(value[:authType])
223
+
224
+ if value[:authType] == 'static'
225
+ UI.user_error!("username must be a String and at least 6 characters long.") unless value[:username].kind_of?(String) && value[:username].length >= 6
226
+ UI.user_error!("password must be a String and at least 6 characters long.") unless value[:password].kind_of?(String) && value[:password].length >= 6
227
+ else
228
+ value[:username] = nil
229
+ value[:password] = nil
230
+ end
231
+ end
232
+
233
+ if value[:testingGroupNames] && !value[:testingGroupNames].empty?
234
+ value[:testingGroupNames] = value[:testingGroupNames].to_s.split(",").map(&:strip)
235
+ UI.user_error!("testingGroupNames must be a string array. Ex: 'group1, group2, group3'.") unless value[:testingGroupNames].kind_of?(Array)
236
+ end
237
+ end),
238
+
149
239
  FastlaneCore::ConfigItem.new(key: :appPath,
150
240
  env_name: "AC_APP_PATH",
151
- description: "Specify the path to your application file. For iOS, this can be a .ipa or .xcarchive file path. For Android, specify the .apk or .appbundle file path",
241
+ description: "Specify the path to your application file. For iOS, this can be a .ipa file path. For Android, specify the .apk or .aab file path",
152
242
  optional: false,
153
- type: String),
243
+ type: String,
244
+ verify_block: proc do |value|
245
+ UI.user_error!("Application file path cannot be empty. Please provide a valid application file path.") unless value && !value.empty?
246
+
247
+ file_extension = File.extname(value).downcase
248
+ unless VALID_EXTENSIONS.include?(file_extension)
249
+ UI.user_error!("Invalid file extension: '#{file_extension}'. For Android, use .apk or .aab. For iOS, use .ipa.")
250
+ end
251
+ end),
154
252
 
155
253
  FastlaneCore::ConfigItem.new(key: :message,
156
254
  env_name: "AC_MESSAGE",
157
- description: "Optional message to include with the distribution to provide additional information to testers or users receiving the build",
255
+ description: "Message to include with the distribution to provide additional information to testers or users receiving the build",
158
256
  optional: false,
159
- type: String)
257
+ type: String,
258
+ verify_block: proc do |value|
259
+ UI.user_error!("Message field cannot be empty. Please provide a message.") unless value && !value.empty?
260
+ end)
160
261
  ]
161
262
  end
162
263
 
@@ -13,7 +13,7 @@ class UserResponse
13
13
  end
14
14
 
15
15
  module TDAuthService
16
- def self.get_ac_token(pat:)
16
+ def self.get_ac_token(pat:, sub_organization_id: nil)
17
17
  endpoint_url = 'https://auth.appcircle.io/auth/v2/token'
18
18
  uri = URI(endpoint_url)
19
19
 
@@ -24,6 +24,7 @@ module TDAuthService
24
24
 
25
25
  # Encode parameters
26
26
  params = { pat: pat }
27
+ params[:subOrganizationId] = sub_organization_id if sub_organization_id
27
28
  request.body = URI.encode_www_form(params)
28
29
 
29
30
  # Make the HTTP request
@@ -31,8 +32,6 @@ module TDAuthService
31
32
  http.request(request)
32
33
  end
33
34
 
34
-
35
-
36
35
  # Check response
37
36
  if response.is_a?(Net::HTTPSuccess)
38
37
  response_data = JSON.parse(response.body)
@@ -43,7 +42,35 @@ module TDAuthService
43
42
 
44
43
  return user
45
44
  else
46
- raise "HTTP Request failed (#{response.code} #{response.message})"
45
+ raise "Error: (#{response.code} #{response.message})."
46
+ end
47
+ end
48
+
49
+ def self.get_organization_id(access_token:, name:)
50
+ endpoint_url = 'https://api.appcircle.io/identity/v1/organizations'
51
+ uri = URI(endpoint_url)
52
+
53
+ # Create HTTP request
54
+ request = Net::HTTP::Get.new(uri)
55
+ request['Authorization'] = "Bearer #{access_token}"
56
+ request['Accept'] = 'application/json'
57
+
58
+ # Make the HTTP request
59
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
60
+ http.request(request)
61
+ end
62
+
63
+ # Check response
64
+ if response.is_a?(Net::HTTPSuccess)
65
+ response_data = JSON.parse(response.body)
66
+ organizations = response_data['data']
67
+ organization = organizations.find { |org| org['name'] == name }
68
+
69
+ raise "Organization with name '#{name}' not found" unless organization
70
+ return organization['id']
71
+
72
+ else
73
+ raise "Error: (#{response.code} #{response.message})"
47
74
  end
48
75
  end
49
76
 
@@ -55,7 +82,7 @@ module TDAuthService
55
82
  request = Net::HTTP::Post.new(uri)
56
83
  request.content_type = 'application/x-www-form-urlencoded'
57
84
  request['Accept'] = 'application/json'
58
-
85
+
59
86
  # Encode parameters
60
87
  params = { 'personal-access-key' => personal_access_key }
61
88
  request.body = URI.encode_www_form(params)
@@ -75,7 +102,7 @@ module TDAuthService
75
102
 
76
103
  return user
77
104
  else
78
- raise "HTTP Request failed (#{response.code} #{response.message})"
105
+ raise "Error: (#{response.code} #{response.message})."
79
106
  end
80
107
  end
81
108
  end
@@ -6,20 +6,80 @@ require 'rest-client'
6
6
  BASE_URL = "https://api.appcircle.io"
7
7
 
8
8
  module TDUploadService
9
+ UI = FastlaneCore::UI
10
+
9
11
  def self.upload_artifact(token:, message:, app:, dist_profile_id:)
10
- url = "https://api.appcircle.io/distribution/v2/profiles/#{dist_profile_id}/app-versions"
12
+ file_path = app
13
+ file_name = File.basename(file_path)
14
+ file_size = File.size(file_path)
15
+
16
+ upload_info_url = "#{BASE_URL}/distribution/v1/profiles/#{dist_profile_id}/app-versions"
11
17
  headers = {
12
18
  Authorization: "Bearer #{token}",
13
- content_type: :multipart # multipart/form-data
14
- }
15
- payload = {
16
- Message: message,
17
- File: File.new(app, 'rb')
19
+ accept: 'application/json'
18
20
  }
19
-
21
+
22
+ uri = URI(upload_info_url)
23
+ uri.query = URI.encode_www_form({
24
+ action: 'uploadInformation',
25
+ fileName: file_name,
26
+ fileSize: file_size
27
+ })
28
+
20
29
  begin
21
- response = RestClient.post(url, payload, headers)
22
- JSON.parse(response.body) rescue response.body
30
+ UI.message("Getting file upload information...")
31
+ response = RestClient.get(uri.to_s, headers)
32
+ upload_info = JSON.parse(response.body)
33
+ if response.code.between?(200, 299)
34
+ UI.success("File upload information retrieved successfully with status code: #{response.code}")
35
+ else
36
+ UI.error("Failed to retrieve file upload information with status code: #{response.code}")
37
+ raise "Failed to retrieve file upload information."
38
+ end
39
+ file_id = upload_info['fileId']
40
+ upload_url = upload_info['uploadUrl']
41
+
42
+ file_content = File.binread(file_path)
43
+ UI.message("Uploading file to Appcircle...")
44
+ response = RestClient.put(
45
+ upload_url,
46
+ file_content,
47
+ { content_type: 'application/octet-stream' }
48
+ )
49
+ if response.code.between?(200, 299)
50
+ UI.success("File upload finished successfully with status code: #{response.code}")
51
+ else
52
+ UI.error("File upload failed with status code: #{response.code}")
53
+ raise "File upload failed."
54
+ end
55
+
56
+ commit_url = "#{BASE_URL}/distribution/v1/profiles/#{dist_profile_id}/app-versions"
57
+ uri = URI(commit_url)
58
+ uri.query = URI.encode_www_form({ action: 'commitFileUpload' })
59
+
60
+ commit_payload = {
61
+ fileId: file_id,
62
+ fileName: file_name,
63
+ message: message
64
+ }.to_json
65
+
66
+ commit_headers = {
67
+ Authorization: "Bearer #{token}",
68
+ content_type: :json,
69
+ accept: 'application/json'
70
+ }
71
+
72
+ UI.message("Committing file upload...")
73
+ commit_response = RestClient.post(uri.to_s, commit_payload, commit_headers)
74
+ if commit_response.code.between?(200, 299)
75
+ result = JSON.parse(commit_response.body)
76
+ UI.success("Commit successful with status code: #{commit_response.code}")
77
+ else
78
+ UI.error("Commit failed with status code: #{commit_response.code}")
79
+ raise "Commit failed with status code: #{commit_response.code}"
80
+ end
81
+
82
+ return result
23
83
  rescue RestClient::ExceptionWithResponse => e
24
84
  raise e
25
85
  rescue StandardError => e
@@ -38,9 +98,26 @@ module TDUploadService
38
98
 
39
99
  begin
40
100
  response = RestClient.get(url, headers)
41
- parsed_response = JSON.parse(response.body)
101
+ JSON.parse(response.body)
102
+ rescue RestClient::ExceptionWithResponse => e
103
+ raise e
104
+ rescue StandardError => e
105
+ raise e
106
+ end
107
+ end
108
+
109
+ def self.get_testing_groups(auth_token:)
110
+ url = "#{BASE_URL}/distribution/v2/testing-groups"
42
111
 
43
- parsed_response
112
+ # Set up the headers with authentication
113
+ headers = {
114
+ Authorization: "Bearer #{auth_token}",
115
+ accept: 'application/json'
116
+ }
117
+
118
+ begin
119
+ response = RestClient.get(url, headers)
120
+ JSON.parse(response.body)
44
121
  rescue RestClient::ExceptionWithResponse => e
45
122
  raise e
46
123
  rescue StandardError => e
@@ -69,7 +146,40 @@ module TDUploadService
69
146
  end
70
147
  end
71
148
 
72
- def self.get_profile_id(authToken, profileName, createProfileIfNotExists)
149
+ def self.update_distribution_profile(profile_id:, auth_type:, username:, password:, testing_group_ids:, auth_token:)
150
+ url = "#{BASE_URL}/distribution/v2/profiles/#{profile_id}"
151
+ headers = {
152
+ Authorization: "Bearer #{auth_token}",
153
+ content_type: :json,
154
+ accept: 'application/json-patch+json'
155
+ }
156
+
157
+ ### Construct the payload
158
+ payload = {}
159
+
160
+ settings_payload = {
161
+ authenticationType: auth_type,
162
+ username: username,
163
+ password: password
164
+ }.compact
165
+
166
+ payload[:settings] = settings_payload unless settings_payload.empty?
167
+ payload[:testingGroupIds] = testing_group_ids unless testing_group_ids&.empty?
168
+
169
+ payload = payload.compact.to_json
170
+ ###
171
+
172
+ begin
173
+ response = RestClient.patch(url, payload, headers)
174
+ JSON.parse(response.body)
175
+ rescue RestClient::ExceptionWithResponse => e
176
+ raise e
177
+ rescue StandardError => e
178
+ raise e
179
+ end
180
+ end
181
+
182
+ def self.get_profile_id(authToken, profileName)
73
183
  profileId = nil
74
184
 
75
185
  begin
@@ -77,27 +187,75 @@ module TDUploadService
77
187
  profiles.each do |profile|
78
188
  if profile["name"] == profileName
79
189
  profileId = profile['id']
190
+ break
80
191
  end
81
192
  end
82
193
  rescue => e
83
- raise "Something went wrong while fetching profiles: #{e.message}"
84
- end
85
-
86
- if profileId.nil? && !createProfileIfNotExists
87
- raise "Error: The test profile '#{profileName}' could not be found. The option 'createProfileIfNotExists' is set to false, so no new profile was created. To automatically create a new profile if it doesn't exist, set 'createProfileIfNotExists' to true."
194
+ raise "Something went wrong while fetching profiles: #{e.message}."
88
195
  end
89
196
 
90
- if profileId.nil? && createProfileIfNotExists
91
- begin
92
- puts "The test profile '#{profileName}' could not be found. A new profile is being created..."
93
- new_profile = TDUploadService.create_distribution_profile(name: profileName, auth_token: authToken)
94
- if new_profile.nil?
95
- raise "Error: The new profile could not be created."
197
+ return profileId
198
+ end
199
+
200
+ def self.get_testing_group_ids(authToken, testingGroupNames)
201
+ testingGroupIds = []
202
+ remainingGroupNames = Set.new(testingGroupNames)
203
+
204
+ begin
205
+ groups = TDUploadService.get_testing_groups(auth_token: authToken)
206
+
207
+ groups.each do |group|
208
+ if remainingGroupNames.include?(group["name"])
209
+ testingGroupIds.push(group['id'])
210
+ remainingGroupNames.delete(group["name"])
96
211
  end
97
- profileId = new_profile['id']
98
- rescue => e
99
- raise "Something went wrong while creating a new profile: #{e.message}"
100
212
  end
213
+ rescue => e
214
+ raise "Something went wrong while fetching testing groups: #{e.message}."
215
+ end
216
+
217
+ raise "Following testing groups couldn't be found: '#{remainingGroupNames.to_a.join(', ')}'. Aborting profile creation..." unless remainingGroupNames.empty?
218
+
219
+ return testingGroupIds
220
+ end
221
+
222
+ def self.create_profile(authToken, profileName, profileAuthType, profileUsername, profilePassword, profileTestingGroupNames)
223
+ # Get testing group IDs
224
+ if !profileTestingGroupNames&.empty?
225
+ profileTestingGroupIds = TDUploadService.get_testing_group_ids(authToken, profileTestingGroupNames)
226
+ end
227
+
228
+ # Create
229
+ begin
230
+ new_profile = TDUploadService.create_distribution_profile(
231
+ name: profileName,
232
+ auth_token: authToken
233
+ )
234
+ if new_profile.nil?
235
+ raise "Error: The new profile could not be created."
236
+ end
237
+ profileId = new_profile['id']
238
+ rescue => e
239
+ raise "Something went wrong while creating a new profile: #{e.message}."
240
+ end
241
+
242
+ # Configure
243
+ begin
244
+ Fastlane::UI.message("Configuring the profile...")
245
+ configured_profile = TDUploadService.update_distribution_profile(
246
+ profile_id: profileId,
247
+ auth_type: profileAuthType,
248
+ username: profileUsername,
249
+ password: profilePassword,
250
+ testing_group_ids: profileTestingGroupIds,
251
+ auth_token: authToken
252
+ )
253
+ if configured_profile.nil?
254
+ raise "Error: The new profile could not be configured."
255
+ end
256
+ profileId = configured_profile['id'] # Should be the same as before
257
+ rescue => e
258
+ raise "Something went wrong while configuring the new profile: #{e.message}."
101
259
  end
102
260
 
103
261
  return profileId
@@ -8,9 +8,6 @@ module Fastlane
8
8
  # class methods that you define here become available in your action
9
9
  # as `Helper::AppcircleTestingDistributionHelper.your_method`
10
10
  #
11
- def self.show_message
12
- UI.message("Hello from the appcircle_testing_distribution plugin helper!")
13
- end
14
11
  end
15
12
  end
16
13
  end
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module AppcircleTestingDistribution
3
- VERSION = "0.4.1"
3
+ VERSION = "0.4.2"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fastlane-plugin-appcircle_testing_distribution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - appcircleio
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-25 00:00:00.000000000 Z
11
+ date: 2026-04-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rest-client
@@ -32,8 +32,6 @@ extra_rdoc_files: []
32
32
  files:
33
33
  - LICENSE
34
34
  - README.md
35
- - images/PAT.png
36
- - images/distribution-start.png
37
35
  - images/extension-icon.png
38
36
  - lib/fastlane/plugin/appcircle_testing_distribution.rb
39
37
  - lib/fastlane/plugin/appcircle_testing_distribution/actions/appcircle_testing_distribution_action.rb
@@ -45,7 +43,7 @@ homepage: https://github.com/appcircleio/fastlane_plugin_appcircle_testing_distr
45
43
  licenses:
46
44
  - MIT
47
45
  metadata:
48
- rubygems_mfa_required: 'true'
46
+ rubygems_mfa_required: 'false'
49
47
  post_install_message:
50
48
  rdoc_options: []
51
49
  require_paths:
data/images/PAT.png DELETED
Binary file
Binary file