Commit b94c88fe authored by Vadim Vlasov's avatar Vadim Vlasov

feat(appgate): upload with organization API keys

appgate_upload no longer requires app_id. Without it the action calls
POST /api/releases with the org key (agk_...) and lets the server find
or create the app by bundle_id + platform. Platform is detected from the
file extension (or set via `platform`), bundle id is read with the
app-info gem (or set via `bundle_id`); `app_name` is passed through.
A 409 lists the matching app ids and asks for app_id. The app_id path
is unchanged. Status polling now surfaces the server's `error` field.
Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 8e3bc58a
...@@ -16,6 +16,50 @@ FuryLion Fastlane plugin ...@@ -16,6 +16,50 @@ FuryLion Fastlane plugin
**Note to author:** Add a more detailed description about this plugin here. If your plugin contains multiple actions, make sure to mention them here. **Note to author:** Add a more detailed description about this plugin here. If your plugin contains multiple actions, make sure to mention them here.
## appgate_upload
Uploads a build to AppGate and exposes the public download page as `ENV["APPGATE_LINK"]`.
With an app API key and `app_id`:
```ruby
appgate_upload(
api_key: ENV["APPGATE_API_KEY"],
app_id: "64f0c0ffee...",
file_path: "build/MyApp.ipa"
)
```
With an organization API key (`agk_...`) and no `app_id`, AppGate finds the app by
bundle id and platform, or creates it on the first upload:
```ruby
appgate_upload(
api_key: ENV["APPGATE_ORG_API_KEY"], # agk_...
file_path: "build/MyApp.apk"
)
```
- `platform` is detected from the file (`.ipa`, `.apk`/`.aab`, `.app.zip`/`.dmg`/`.pkg`, `.exe`/`.msi`/`.msix`/`.appx`...).
Set it for WebGL and for a plain `.zip` that is not a macOS app.
- `bundle_id` is read from the build with the `app-info` gem. Set it for WebGL and for
files it cannot be read from (e.g. a Windows `.exe`).
- `app_name` names the app if AppGate has to create it.
- If several apps of the organization share the bundle id and platform, the upload fails
with their ids; pass `app_id` to pick one.
```ruby
appgate_upload(
api_key: ENV["APPGATE_ORG_API_KEY"],
file_path: "build/webgl.zip",
platform: "WebGL",
bundle_id: "com.example.mygame.web",
app_name: "My Game (Web)",
version: "1.2.0",
build_number: "42"
)
```
## Example ## Example
Check out the [example `Fastfile`](fastlane/Fastfile) to see how to use this plugin. Try it by cloning the repo, running `fastlane install_plugins` and `bundle exec fastlane test`. Check out the [example `Fastfile`](fastlane/Fastfile) to see how to use this plugin. Try it by cloning the repo, running `fastlane install_plugins` and `bundle exec fastlane test`.
......
...@@ -34,4 +34,6 @@ Gem::Specification.new do |spec| ...@@ -34,4 +34,6 @@ Gem::Specification.new do |spec|
spec.add_development_dependency('fastlane', '>= 2.129.0') spec.add_development_dependency('fastlane', '>= 2.129.0')
spec.add_runtime_dependency 'faraday', '~> 1.0' spec.add_runtime_dependency 'faraday', '~> 1.0'
# Published as app-info, required as 'app_info'; reads bundle ids for the org API key flow.
spec.add_runtime_dependency 'app-info', '>= 3.0'
end end
...@@ -23,6 +23,9 @@ module Fastlane ...@@ -23,6 +23,9 @@ module Fastlane
branch = "" branch = ""
end end
if app_id.nil? || app_id.to_s.strip.empty?
release_data = create_org_release(params, branch)
else
UI.message("Starting release upload...") UI.message("Starting release upload...")
release_data = Helper::AppGateHelper.create_release( release_data = Helper::AppGateHelper.create_release(
api_key, api_key,
...@@ -33,6 +36,7 @@ module Fastlane ...@@ -33,6 +36,7 @@ module Fastlane
version, version,
build_number build_number
) )
end
UI.abort_with_message!("Failed to create release") unless release_data UI.abort_with_message!("Failed to create release") unless release_data
release_id = release_data[:release_id] release_id = release_data[:release_id]
...@@ -70,6 +74,41 @@ module Fastlane ...@@ -70,6 +74,41 @@ module Fastlane
share_url share_url
end end
# Org API key flow: the server resolves (or creates) the app by bundle_id + platform.
def self.create_org_release(params, branch)
file_path = params[:file_path]
platform = Helper::AppGateHelper.normalize_platform(params[:platform]) ||
Helper::AppGateHelper.detect_platform(file_path)
bundle_id = params[:bundle_id].to_s.strip
bundle_id = Helper::AppGateHelper.extract_bundle_id(file_path, platform) if bundle_id.empty?
body = {
branch: branch,
size: File.size(file_path),
file_name: File.basename(file_path),
bundle_id: bundle_id,
platform: platform
}
version = params[:version]
build_number = params[:build_number]
app_name = params[:app_name]
body[:version] = version if version && !version.empty?
body[:build_number] = build_number if build_number && !build_number.empty?
body[:app_name] = app_name if app_name && !app_name.to_s.strip.empty?
UI.message("Starting release upload (#{platform}, bundle id #{bundle_id})...")
release_data = Helper::AppGateHelper.create_org_release(params[:api_key], body)
return nil unless release_data && release_data[:release_id] && release_data[:upload_token]
if release_data[:app_created]
UI.success("AppGate created a new app for #{bundle_id}: app_id #{release_data[:app_id]}")
else
UI.message("AppGate app_id: #{release_data[:app_id]} (existing app)")
end
release_data
end
##################################################### #####################################################
# @!group Documentation # @!group Documentation
##################################################### #####################################################
...@@ -92,8 +131,8 @@ module Fastlane ...@@ -92,8 +131,8 @@ module Fastlane
[ [
FastlaneCore::ConfigItem.new(key: :app_id, FastlaneCore::ConfigItem.new(key: :app_id,
env_name: "APPGATE_APP_ID", env_name: "APPGATE_APP_ID",
description: "AppGate App ID", description: "AppGate App ID. Set it to upload with the app's own API key. Leave it empty to upload with an organization API key (agk_...): the app is then found or created by bundle id and platform. With an org key, set it only to pick one of several apps that share the bundle id",
optional: false, optional: true,
default_value: ""), default_value: ""),
FastlaneCore::ConfigItem.new(key: :file_path, FastlaneCore::ConfigItem.new(key: :file_path,
env_name: "APPGATE_FILE_PATH", env_name: "APPGATE_FILE_PATH",
...@@ -114,8 +153,23 @@ module Fastlane ...@@ -114,8 +153,23 @@ module Fastlane
type: Boolean), type: Boolean),
FastlaneCore::ConfigItem.new(key: :api_key, FastlaneCore::ConfigItem.new(key: :api_key,
env_name: "APPGATE_API_KEY", env_name: "APPGATE_API_KEY",
description: "API key for AppGate", description: "API key for AppGate: the app's API key (with app_id) or an organization API key agk_... (without app_id)",
sensitive: true), sensitive: true),
FastlaneCore::ConfigItem.new(key: :platform,
env_name: "APPGATE_PLATFORM",
description: "Org key flow only: target platform (iOS, Android, macOS, Windows, WebGL). Detected from the file when omitted; required for WebGL and ambiguous .zip files",
optional: true,
default_value: nil),
FastlaneCore::ConfigItem.new(key: :bundle_id,
env_name: "APPGATE_BUNDLE_ID",
description: "Org key flow only: bundle id / package name of the app. Read from the build when omitted; required for WebGL and for files it cannot be read from",
optional: true,
default_value: nil),
FastlaneCore::ConfigItem.new(key: :app_name,
env_name: "APPGATE_APP_NAME",
description: "Org key flow only: display name used if AppGate creates a new app",
optional: true,
default_value: nil),
FastlaneCore::ConfigItem.new(key: :skip_waiting_for_release, FastlaneCore::ConfigItem.new(key: :skip_waiting_for_release,
env_name: "APPGATE_SKIP_WAITING_FOR_RELEASE", env_name: "APPGATE_SKIP_WAITING_FOR_RELEASE",
description: "Skip waiting for release processing", description: "Skip waiting for release processing",
......
...@@ -16,7 +16,8 @@ module Fastlane ...@@ -16,7 +16,8 @@ module Fastlane
RELEASE_UPLOAD_STATUS_POLL_INTERVAL = 1 RELEASE_UPLOAD_STATUS_POLL_INTERVAL = 1
def self.verify_required_params(params) def self.verify_required_params(params)
required_params = [:api_key, :app_id, :file_path] # app_id is optional: without it the org-key flow resolves the app server-side.
required_params = [:api_key, :file_path]
required_params.each do |param| required_params.each do |param|
UI.user_error!("Missing required parameter: #{param}") unless params[param] UI.user_error!("Missing required parameter: #{param}") unless params[param]
end end
...@@ -108,6 +109,136 @@ module Fastlane ...@@ -108,6 +109,136 @@ module Fastlane
nil nil
end end
# ---- Organization API key flow ----------------------------------------
PLATFORMS = %w(iOS Android macOS Windows WebGL).freeze
WINDOWS_EXTENSIONS = %w(.exe .msi .msix .msixbundle .appx .appxbundle).freeze
MACOS_EXTENSIONS = %w(.app.zip .dmg .pkg).freeze
def self.normalize_platform(value)
return nil if value.nil? || value.to_s.strip.empty?
found = PLATFORMS.find { |p| p.casecmp(value.to_s.strip).zero? }
UI.user_error!("Unknown platform '#{value}'. Use one of: #{PLATFORMS.join(', ')}") unless found
found
end
def self.detect_platform(file_path)
name = File.basename(file_path).downcase
return 'iOS' if name.end_with?('.ipa')
return 'Android' if name.end_with?('.apk', '.aab')
return 'macOS' if name.end_with?(*MACOS_EXTENSIONS)
return 'Windows' if name.end_with?(*WINDOWS_EXTENSIONS)
# A plain .zip may be a macOS app, a Windows build or WebGL; only a .app bundle is detectable.
if name.end_with?('.zip')
format = begin
load_app_info
AppInfo.file_type(file_path)
rescue StandardError, LoadError
nil
end
return 'macOS' if format && format == AppInfo::Format::MACOS
end
UI.user_error!("Could not detect the platform of '#{File.basename(file_path)}'. " \
"Pass `platform` (one of: #{PLATFORMS.join(', ')}).")
end
def self.extract_bundle_id(file_path, platform)
if platform == 'WebGL'
UI.user_error!("`bundle_id` is required for WebGL builds (it cannot be read from the file).")
end
bundle_id = nil
begin
load_app_info
# The block form cleans up extracted temp files; it returns clear!'s result, so capture inside.
AppInfo.parse(file_path) do |parser|
bundle_id = parser.bundle_id if parser.respond_to?(:bundle_id)
end
rescue StandardError, LoadError => e
UI.user_error!("Could not read bundle id from '#{File.basename(file_path)}' (#{e.message}). " \
"Pass `bundle_id` explicitly.")
end
if bundle_id.nil? || bundle_id.to_s.strip.empty?
UI.user_error!("No bundle id found in '#{File.basename(file_path)}'. Pass `bundle_id` explicitly.")
end
bundle_id.to_s.strip
end
def self.load_app_info
require 'app_info'
rescue LoadError => e
raise LoadError, "the app-info gem is not available (#{e.message})"
end
def self.create_org_release(api_key, body)
url = "#{BASE_URL}/releases"
retries = 0
loop do
response = begin
Faraday.new.post(url) do |req|
req.headers['Authorization'] = "Bearer #{api_key}"
req.headers['Content-Type'] = 'application/json'
req.body = body.to_json
end
rescue Faraday::Error => e
UI.error("Network error: #{e}")
nil
end
if response && response.success?
data = JSON.parse(response.body)
return {
release_id: data['release_id'],
upload_token: data['upload_token'],
share_url: data['share_url'],
share_token: data['share_token'],
app_id: data['app_id'],
app_created: data['app_created']
}
end
# 4xx are definitive answers from the server; retrying would not change them.
if response && response.status >= 400 && response.status < 500
handle_org_release_error(response)
end
UI.error("Error: #{response.status} - #{response.body}") if response
if retries >= MAX_RETRIES
UI.user_error!("Max retries reached. Request failed.")
end
retries += 1
UI.message("Retrying in #{RETRY_DELAY} seconds (attempt #{retries}/#{MAX_RETRIES})...")
sleep(RETRY_DELAY)
end
end
def self.handle_org_release_error(response)
data = begin
JSON.parse(response.body.to_s)
rescue JSON::ParserError
{}
end
data = {} unless data.kind_of?(Hash)
message = data['error'] || response.body.to_s
case response.status
when 409
ids = Array(data['app_ids'])
UI.user_error!("Several AppGate apps match this bundle id and platform: #{ids.join(', ')}. " \
"Pass `app_id` to choose one. (#{message})")
when 401, 403
UI.user_error!("AppGate rejected the request (#{response.status}): #{message}. " \
"Check that the API key is valid, not revoked, and that `app_id` (if set) belongs to its organization.")
else
UI.user_error!("AppGate rejected the request (#{response.status}): #{message}")
end
end
# --------------------------------------------------------------------------
def self.upload_file(release_id, file, upload_token) def self.upload_file(release_id, file, upload_token)
File.open(file, 'rb') do |f| File.open(file, 'rb') do |f|
chunk_number = 0 chunk_number = 0
...@@ -256,7 +387,8 @@ module Fastlane ...@@ -256,7 +387,8 @@ module Fastlane
when 'UploadFinished' when 'UploadFinished'
return true return true
when 'UploadFailed' when 'UploadFailed'
UI.user_error!("Upload failed: #{response['error_message']}") reason = response['error'] || response['error_message']
UI.user_error!("Upload failed: #{reason}")
return false return false
end end
else else
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment