fastlane-plugin-apkgo 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: 06e4b13ad0439c4a65ae89e321f246ef07902bd172173d5bd07008b066addce7
4
+ data.tar.gz: 6e6d3513f06c7ed5f398461333a788d11cf4d17659cdeb5f61bca30f37c864eb
5
+ SHA512:
6
+ metadata.gz: 054a3b4bbf0d5ec0e442a3155060348160290fff57744b3c0ab8ee4042d027e29372733cf36da63296056cef913e18ea791acdcb358d3b010b6bf7824412372c
7
+ data.tar.gz: f5c23b0bca061ed1779ff2a62789a085352f8a7f3b828e9193e3312d78d4ae7087f6cf170381d1492019e3cb50b738e3cb10cbbd0237922ada1c3c0c08185d0e
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KevinGong2013
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,115 @@
1
+ # fastlane-plugin-apkgo
2
+
3
+ [![Gem](https://img.shields.io/gem/v/fastlane-plugin-apkgo.svg)](https://rubygems.org/gems/fastlane-plugin-apkgo)
4
+ [![CI](https://github.com/KevinGong2013/fastlane-plugin-apkgo/actions/workflows/ci.yml/badge.svg)](https://github.com/KevinGong2013/fastlane-plugin-apkgo/actions)
5
+
6
+ [fastlane](https://fastlane.tools) plugin to publish an APK to **all major
7
+ Chinese Android app stores** in one step — via [**apkgo cloud**](https://apkgo.baici.tech).
8
+
9
+ > 一行 lane,将 APK 发布到华为 / 小米 / OPPO / vivo / 荣耀 / 应用宝 / 三星 /
10
+ > Google Play / 蒲公英 / fir 等所有主流安卓商店。凭证云端托管,发布历史可追溯。
11
+
12
+ ## Getting started
13
+
14
+ Add the plugin to your project:
15
+
16
+ ```bash
17
+ fastlane add_plugin apkgo
18
+ ```
19
+
20
+ Or add to your `Pluginfile`:
21
+
22
+ ```ruby
23
+ gem "fastlane-plugin-apkgo"
24
+ ```
25
+
26
+ ## Prerequisites
27
+
28
+ This plugin drives the apkgo cloud **Open API**. Before using it:
29
+
30
+ 1. Sign up at [apkgo.baici.tech](https://apkgo.baici.tech).
31
+ 2. **Configure store credentials and bind them to your app** in the dashboard.
32
+ The plugin targets stores *by name* — it cannot create credentials.
33
+ 3. Create an **API key** (Settings → API Keys) with the `upload` permission and
34
+ put it in CI as `APKGO_API_KEY`.
35
+
36
+ ## Usage
37
+
38
+ ```ruby
39
+ lane :release do
40
+ gradle(task: "assembleRelease")
41
+
42
+ upload_to_apkgo(
43
+ api_key: ENV["APKGO_API_KEY"],
44
+ apk: lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH],
45
+ package_name: "com.example.myapp",
46
+ version_name: "1.2.3",
47
+ release_notes: "Bug fixes and improvements",
48
+ stores: ["huawei", "xiaomi", "oppo"] # omit = all stores bound to the app
49
+ )
50
+ end
51
+ ```
52
+
53
+ `apk` defaults to the output of the `gradle` action, so you can usually omit it.
54
+
55
+ ### Scheduled release (定时发布)
56
+
57
+ ```ruby
58
+ upload_to_apkgo(
59
+ api_key: ENV["APKGO_API_KEY"],
60
+ package_name: "com.example.myapp",
61
+ release_time: "2026-06-20T10:00:00+08:00" # RFC3339, must be in the future
62
+ )
63
+ ```
64
+
65
+ Stores that support scheduling honor it; others release immediately after review.
66
+
67
+ ## Options
68
+
69
+ | Option | Env | Default | Description |
70
+ |---|---|---|---|
71
+ | `api_key` | `APKGO_API_KEY` | — | Open API key (`apkgo_…`), needs `upload` permission |
72
+ | `apk` | `APKGO_APK` | gradle output | Path to the APK |
73
+ | `host` | `APKGO_HOST` | `https://apkgo.baici.tech` | apkgo cloud base URL (for self-hosted) |
74
+ | `package_name` | `APKGO_PACKAGE_NAME` | — | Required unless `app_id` is set |
75
+ | `app_id` | `APKGO_APP_ID` | — | apkgo cloud app UUID (alternative to package_name) |
76
+ | `app_name` | `APKGO_APP_NAME` | — | Display name when auto-creating the app |
77
+ | `version_code` | `APKGO_VERSION_CODE` | — | Informational; the worker re-parses the APK |
78
+ | `version_name` | `APKGO_VERSION_NAME` | — | Informational |
79
+ | `release_notes` | `APKGO_RELEASE_NOTES` | — | Update notes for each store |
80
+ | `release_time` | `APKGO_RELEASE_TIME` | — | Scheduled release, RFC3339 in the future |
81
+ | `stores` | `APKGO_STORES` | all bound | Target store names, e.g. `["huawei","xiaomi"]` |
82
+ | `wait` | `APKGO_WAIT` | `true` | Poll until done; fail the lane if any store fails |
83
+ | `poll_interval` | `APKGO_POLL_INTERVAL` | `5` | Seconds between status polls |
84
+ | `timeout` | `APKGO_TIMEOUT` | `1800` | Max seconds to wait |
85
+
86
+ ## Output
87
+
88
+ | Shared value | Description |
89
+ |---|---|
90
+ | `APKGO_JOB_ID` | The created upload job id |
91
+ | `APKGO_JOB` | The full job object, including per-store results |
92
+
93
+ ## How it works
94
+
95
+ The APK bytes never transit the apkgo cloud API server — the plugin:
96
+
97
+ 1. requests a direct-to-storage upload **ticket** (`POST /openapi/v1/uploads/tickets`);
98
+ 2. uploads the APK straight to object storage (七牛 Kodo);
99
+ 3. creates the job (`POST /openapi/v1/uploads`);
100
+ 4. polls (`GET /openapi/v1/uploads/{id}`) until every store finishes, surfacing
101
+ per-store success and store-side review (审核) state.
102
+
103
+ See the [Open API reference](https://github.com/KevinGong2013/apkgo-cloud/blob/main/docs/openapi.md).
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ bundle install
109
+ bundle exec rspec # tests
110
+ bundle exec rubocop # lint
111
+ ```
112
+
113
+ ## License
114
+
115
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,251 @@
1
+ require "fastlane/action"
2
+ require_relative "../helper/apkgo_helper"
3
+
4
+ module Fastlane
5
+ module Actions
6
+ module SharedValues
7
+ APKGO_JOB_ID = :APKGO_JOB_ID
8
+ APKGO_JOB = :APKGO_JOB
9
+ end
10
+
11
+ # upload_to_apkgo publishes an APK to Chinese app stores through the
12
+ # apkgo cloud Open API. Stores must be pre-configured (credentials + app
13
+ # bindings) in the apkgo cloud dashboard; this action targets them by name.
14
+ class UploadToApkgoAction < Action
15
+ DEFAULT_HOST = "https://apkgo.baici.tech".freeze
16
+
17
+ def self.run(params)
18
+ apk = params[:apk]
19
+ UI.user_error!("apkgo: 找不到 APK 文件: #{apk}") unless apk && File.exist?(apk)
20
+
21
+ client = Helper::ApkgoClient.new(host: params[:host], api_key: params[:api_key])
22
+
23
+ UI.message("apkgo: 申请上传凭证 …")
24
+ ticket = client.request_ticket(File.basename(apk))
25
+
26
+ UI.message("apkgo: 上传 APK 到对象存储 (provider=#{ticket['provider']}) …")
27
+ client.upload_to_storage(ticket, apk)
28
+
29
+ body = build_body(params, ticket, apk)
30
+ UI.message("apkgo: 创建上传任务 …")
31
+ job = client.create_job(body)
32
+ job_id = job["id"]
33
+
34
+ Actions.lane_context[SharedValues::APKGO_JOB_ID] = job_id
35
+ Actions.lane_context[SharedValues::APKGO_JOB] = job
36
+ UI.success("apkgo: 任务已创建 #{job_id} → 目标商店 #{target_names(job)}")
37
+
38
+ return job unless params[:wait]
39
+
40
+ poll(client, job_id, params)
41
+ end
42
+
43
+ def self.build_body(params, ticket, apk)
44
+ body = {
45
+ object_key: ticket["object_key"],
46
+ sha256: Helper::ApkgoHelper.sha256(apk)
47
+ }
48
+ body[:app_id] = params[:app_id] if params[:app_id]
49
+ body[:package_name] = params[:package_name] if params[:package_name]
50
+ body[:app_name] = params[:app_name] if params[:app_name]
51
+ body[:version_code] = params[:version_code] if params[:version_code]
52
+ body[:version_name] = params[:version_name] if params[:version_name]
53
+ body[:release_notes] = params[:release_notes] if params[:release_notes]
54
+ body[:release_time] = params[:release_time] if params[:release_time]
55
+ body[:target_stores] = params[:stores] if params[:stores] && !params[:stores].empty?
56
+ body
57
+ end
58
+
59
+ # poll waits for the job to reach a terminal state, surfacing per-store
60
+ # outcomes. Raises if any store failed so the lane fails loudly in CI.
61
+ def self.poll(client, job_id, params)
62
+ interval = params[:poll_interval]
63
+ deadline = Time.now + params[:timeout]
64
+ last_status = nil
65
+
66
+ loop do
67
+ job = client.get_job(job_id)
68
+ status = job["status"]
69
+ if status != last_status
70
+ UI.message("apkgo: 任务状态 #{status}")
71
+ last_status = status
72
+ end
73
+ log_progress(job)
74
+
75
+ if Helper::ApkgoClient::TERMINAL_STATUSES.include?(status)
76
+ Actions.lane_context[SharedValues::APKGO_JOB] = job
77
+ return finish(job)
78
+ end
79
+
80
+ UI.user_error!("apkgo: 轮询超时(#{params[:timeout]}s),任务 #{job_id} 仍未完成。可稍后用 dashboard 查看。") if Time.now > deadline
81
+ sleep(interval)
82
+ end
83
+ end
84
+
85
+ def self.log_progress(job)
86
+ (job["progress"] || {}).each do |store, p|
87
+ next unless p["bytes_total"].to_i.positive?
88
+
89
+ pct = (100.0 * p["bytes_done"].to_i / p["bytes_total"].to_i).round
90
+ UI.message(" #{store}: #{p['phase']} #{pct}%")
91
+ end
92
+ end
93
+
94
+ # finish reports each store result and fails the lane if any store failed.
95
+ def self.finish(job)
96
+ results = job["results"] || []
97
+ failed = []
98
+ results.each do |r|
99
+ if r["success"]
100
+ review = r["review_state"] ? " (审核: #{r['review_state']})" : ""
101
+ UI.success(" ✔ #{r['store_name']}#{review}")
102
+ else
103
+ failed << r
104
+ UI.error(" ✗ #{r['store_name']}: #{r['error']}")
105
+ end
106
+ end
107
+
108
+ if job["status"] != "completed" || !failed.empty?
109
+ names = failed.map { |r| r["store_name"] }.join(", ")
110
+ UI.user_error!("apkgo: 任务 #{job['status']},失败商店: #{names.empty? ? job['status'] : names}")
111
+ end
112
+
113
+ UI.success("apkgo: 全部商店上传成功 🎉")
114
+ job
115
+ end
116
+
117
+ def self.target_names(job)
118
+ (job["target_stores"] || []).map { |t| t["store_name"] }.join(", ")
119
+ end
120
+
121
+ #####################################################
122
+ # @!group Documentation
123
+ #####################################################
124
+
125
+ def self.description
126
+ "Publish an APK to Chinese app stores via the apkgo cloud Open API"
127
+ end
128
+
129
+ def self.details
130
+ "Uploads an APK to apkgo cloud, which fans it out to the Android app " \
131
+ "stores you have configured (Huawei, Xiaomi, OPPO, vivo, Honor, " \
132
+ "Tencent, Samsung, Google Play, pgyer, fir …). Store credentials and " \
133
+ "app bindings are managed in the apkgo cloud dashboard; this action " \
134
+ "targets them by store name. See https://github.com/KevinGong2013/fastlane-plugin-apkgo"
135
+ end
136
+
137
+ def self.available_options
138
+ [
139
+ FastlaneCore::ConfigItem.new(key: :api_key,
140
+ env_name: "APKGO_API_KEY",
141
+ description: "apkgo cloud Open API key (apkgo_…), needs the `upload` permission",
142
+ sensitive: true,
143
+ verify_block: proc do |value|
144
+ UI.user_error!("apkgo: api_key 不能为空") if value.to_s.empty?
145
+ UI.user_error!("apkgo: api_key 应以 apkgo_ 开头") unless value.start_with?("apkgo_")
146
+ end),
147
+ FastlaneCore::ConfigItem.new(key: :apk,
148
+ env_name: "APKGO_APK",
149
+ description: "Path to the APK to upload",
150
+ default_value: Actions.lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH],
151
+ default_value_dynamic: true,
152
+ verify_block: proc do |value|
153
+ UI.user_error!("apkgo: 找不到 APK 文件: #{value}") unless File.exist?(value.to_s)
154
+ end),
155
+ FastlaneCore::ConfigItem.new(key: :host,
156
+ env_name: "APKGO_HOST",
157
+ description: "apkgo cloud base URL",
158
+ default_value: DEFAULT_HOST),
159
+ FastlaneCore::ConfigItem.new(key: :package_name,
160
+ env_name: "APKGO_PACKAGE_NAME",
161
+ description: "App package name (required unless app_id is set)",
162
+ optional: true),
163
+ FastlaneCore::ConfigItem.new(key: :app_id,
164
+ env_name: "APKGO_APP_ID",
165
+ description: "apkgo cloud app UUID (alternative to package_name)",
166
+ optional: true),
167
+ FastlaneCore::ConfigItem.new(key: :app_name,
168
+ env_name: "APKGO_APP_NAME",
169
+ description: "Display name when auto-creating the app",
170
+ optional: true),
171
+ FastlaneCore::ConfigItem.new(key: :version_code,
172
+ env_name: "APKGO_VERSION_CODE",
173
+ description: "versionCode (informational; the worker re-parses the APK)",
174
+ optional: true,
175
+ type: Integer),
176
+ FastlaneCore::ConfigItem.new(key: :version_name,
177
+ env_name: "APKGO_VERSION_NAME",
178
+ description: "versionName (informational)",
179
+ optional: true),
180
+ FastlaneCore::ConfigItem.new(key: :release_notes,
181
+ env_name: "APKGO_RELEASE_NOTES",
182
+ description: "Release notes / 更新日志 passed to each store",
183
+ optional: true),
184
+ FastlaneCore::ConfigItem.new(key: :release_time,
185
+ env_name: "APKGO_RELEASE_TIME",
186
+ description: "Scheduled release time (定时发布), RFC3339 in the future e.g. 2026-06-20T10:00:00+08:00",
187
+ optional: true),
188
+ FastlaneCore::ConfigItem.new(key: :stores,
189
+ env_name: "APKGO_STORES",
190
+ description: "Target store names, e.g. [\"huawei\",\"xiaomi\"]. Empty = all bound stores",
191
+ optional: true,
192
+ type: Array),
193
+ FastlaneCore::ConfigItem.new(key: :wait,
194
+ env_name: "APKGO_WAIT",
195
+ description: "Poll until the job finishes and fail the lane if any store fails",
196
+ optional: true,
197
+ default_value: true,
198
+ type: Boolean),
199
+ FastlaneCore::ConfigItem.new(key: :poll_interval,
200
+ env_name: "APKGO_POLL_INTERVAL",
201
+ description: "Seconds between status polls",
202
+ optional: true,
203
+ default_value: 5,
204
+ type: Integer),
205
+ FastlaneCore::ConfigItem.new(key: :timeout,
206
+ env_name: "APKGO_TIMEOUT",
207
+ description: "Max seconds to wait for the job to finish",
208
+ optional: true,
209
+ default_value: 1800,
210
+ type: Integer)
211
+ ]
212
+ end
213
+
214
+ def self.output
215
+ [
216
+ ["APKGO_JOB_ID", "The created upload job id"],
217
+ ["APKGO_JOB", "The full job object (with per-store results)"]
218
+ ]
219
+ end
220
+
221
+ def self.return_value
222
+ "The final job hash, including per-store results when `wait` is true"
223
+ end
224
+
225
+ def self.authors
226
+ ["KevinGong2013"]
227
+ end
228
+
229
+ def self.is_supported?(platform)
230
+ platform == :android
231
+ end
232
+
233
+ def self.example_code
234
+ [
235
+ 'upload_to_apkgo(
236
+ api_key: ENV["APKGO_API_KEY"],
237
+ apk: lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH],
238
+ package_name: "com.example.myapp",
239
+ version_name: "1.2.3",
240
+ release_notes: "Bug fixes and improvements",
241
+ stores: ["huawei", "xiaomi", "oppo"]
242
+ )'
243
+ ]
244
+ end
245
+
246
+ def self.category
247
+ :production
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,172 @@
1
+ require "fastlane_core/ui/ui"
2
+ require "net/http"
3
+ require "uri"
4
+ require "json"
5
+ require "digest"
6
+ require "securerandom"
7
+ require "stringio"
8
+
9
+ module Fastlane
10
+ UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI)
11
+
12
+ module Helper
13
+ # ApkgoClient talks to the apkgo cloud Open API (`/openapi/v1`).
14
+ #
15
+ # The flow is deliberately three steps because the APK bytes never transit
16
+ # the apkgo cloud server — they go straight to object storage:
17
+ # 1. POST /uploads/tickets → a direct-to-storage upload ticket
18
+ # 2. upload the APK to that storage (qiniu multipart, or dev PUT)
19
+ # 3. POST /uploads → register the job around the stored object
20
+ # 4. GET /uploads/{id} → poll until terminal
21
+ class ApkgoClient
22
+ # Job lifecycle states (mirrors the backend's domain.UploadStatus).
23
+ TERMINAL_STATUSES = %w[completed failed cancelled].freeze
24
+
25
+ def initialize(host:, api_key:)
26
+ @host = host.to_s.sub(%r{/+\z}, "")
27
+ @api_key = api_key
28
+ end
29
+
30
+ # request_ticket asks the server for a direct-upload ticket for `file_name`.
31
+ # The server chooses the object key; we never get to.
32
+ def request_ticket(file_name)
33
+ api_post("/openapi/v1/uploads/tickets", { file_name: file_name })
34
+ end
35
+
36
+ # upload_to_storage places the APK bytes where the ticket points. Returns
37
+ # nothing; raises on any non-2xx.
38
+ def upload_to_storage(ticket, apk_path)
39
+ case ticket["provider"]
40
+ when "qiniu"
41
+ qiniu_form_upload(ticket, apk_path)
42
+ when "direct"
43
+ direct_put(ticket, apk_path)
44
+ else
45
+ UI.user_error!("apkgo: 未知的上传 provider: #{ticket['provider'].inspect}")
46
+ end
47
+ end
48
+
49
+ # create_job registers the upload job around an already-stored object.
50
+ def create_job(body)
51
+ api_post("/openapi/v1/uploads", body)
52
+ end
53
+
54
+ # get_job fetches a job with its per-store results and live progress.
55
+ def get_job(job_id)
56
+ api_get("/openapi/v1/uploads/#{job_id}")
57
+ end
58
+
59
+ private
60
+
61
+ # --- storage upload backends -----------------------------------------
62
+
63
+ # qiniu_form_upload does a multipart/form-data POST with the three fields
64
+ # the put-policy expects: token, key, file. No qiniu SDK needed — the
65
+ # token is already signed and scoped to exactly this key.
66
+ def qiniu_form_upload(ticket, apk_path)
67
+ uri = URI.parse(ticket["upload_url"])
68
+ boundary = "----apkgofastlane#{SecureRandom.hex(16)}"
69
+ body = build_multipart(boundary, {
70
+ "token" => ticket["token"],
71
+ "key" => ticket["object_key"]
72
+ }, apk_path)
73
+
74
+ req = Net::HTTP::Post.new(uri)
75
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
76
+ req.body = body
77
+
78
+ res = http_for(uri).request(req)
79
+ return if res.is_a?(Net::HTTPSuccess)
80
+
81
+ UI.user_error!("apkgo: 上传 APK 到对象存储失败 (#{res.code}): #{res.body}")
82
+ end
83
+
84
+ # direct_put streams the raw bytes with PUT — the dev/local-disk backend.
85
+ def direct_put(ticket, apk_path)
86
+ url = ticket["upload_url"]
87
+ # The dev backend returns a server-relative URL; resolve against host.
88
+ url = "#{@host}#{url}" if url.start_with?("/")
89
+ uri = URI.parse(url)
90
+
91
+ req = Net::HTTP::Put.new(uri)
92
+ req["Content-Type"] = "application/octet-stream"
93
+ req.body_stream = File.open(apk_path, "rb")
94
+ req["Content-Length"] = File.size(apk_path).to_s
95
+
96
+ res = http_for(uri).request(req)
97
+ return if res.is_a?(Net::HTTPSuccess)
98
+
99
+ UI.user_error!("apkgo: 上传 APK 到存储失败 (#{res.code}): #{res.body}")
100
+ end
101
+
102
+ # build_multipart assembles a multipart body, streaming the file part from
103
+ # disk so a 1–2GB APK doesn't all sit in a Ruby string at once.
104
+ def build_multipart(boundary, fields, file_path)
105
+ io = StringIO.new
106
+ fields.each do |name, value|
107
+ io << "--#{boundary}\r\n"
108
+ io << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n"
109
+ io << "#{value}\r\n"
110
+ end
111
+ io << "--#{boundary}\r\n"
112
+ io << "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(file_path)}\"\r\n"
113
+ io << "Content-Type: application/octet-stream\r\n\r\n"
114
+ io << File.binread(file_path)
115
+ io << "\r\n--#{boundary}--\r\n"
116
+ io.string
117
+ end
118
+
119
+ # --- Open API plumbing -----------------------------------------------
120
+
121
+ def api_post(path, body)
122
+ uri = URI.parse("#{@host}#{path}")
123
+ req = Net::HTTP::Post.new(uri)
124
+ req["Content-Type"] = "application/json"
125
+ req["X-API-Key"] = @api_key
126
+ req.body = JSON.generate(body)
127
+ send_api(uri, req)
128
+ end
129
+
130
+ def api_get(path)
131
+ uri = URI.parse("#{@host}#{path}")
132
+ req = Net::HTTP::Get.new(uri)
133
+ req["X-API-Key"] = @api_key
134
+ send_api(uri, req)
135
+ end
136
+
137
+ # send_api executes the request and unwraps the `{data}` / `{error}`
138
+ # envelope every Open API endpoint returns.
139
+ def send_api(uri, req)
140
+ res = http_for(uri).request(req)
141
+ parsed = begin
142
+ res.body.to_s.empty? ? {} : JSON.parse(res.body)
143
+ rescue JSON::ParserError
144
+ UI.user_error!("apkgo: 服务器返回非 JSON 响应 (#{res.code}): #{res.body}")
145
+ end
146
+
147
+ unless res.is_a?(Net::HTTPSuccess)
148
+ msg = parsed["error"] || res.body
149
+ UI.user_error!("apkgo: 请求失败 (#{res.code}): #{msg}")
150
+ end
151
+ parsed["data"]
152
+ end
153
+
154
+ def http_for(uri)
155
+ http = Net::HTTP.new(uri.host, uri.port)
156
+ http.use_ssl = (uri.scheme == "https")
157
+ # APK uploads are large and store SDKs are slow — be generous.
158
+ http.read_timeout = 1800
159
+ http.open_timeout = 30
160
+ http
161
+ end
162
+ end
163
+
164
+ class ApkgoHelper
165
+ # show_env logs the resolved configuration (without the key) so failing CI
166
+ # runs are debuggable.
167
+ def self.sha256(path)
168
+ Digest::SHA256.file(path).hexdigest
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module Apkgo
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ require "fastlane/plugin/apkgo/version"
2
+
3
+ module Fastlane
4
+ module Apkgo
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::Apkgo.all_classes.each do |current|
15
+ require current
16
+ end
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fastlane-plugin-apkgo
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - KevinGong2013
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: fastlane
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 2.200.0
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.200.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '='
74
+ - !ruby/object:Gem::Version
75
+ version: 1.50.2
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '='
81
+ - !ruby/object:Gem::Version
82
+ version: 1.50.2
83
+ - !ruby/object:Gem::Dependency
84
+ name: webmock
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description:
98
+ email: aoxianglele2010@gmail.com
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - LICENSE
104
+ - README.md
105
+ - lib/fastlane/plugin/apkgo.rb
106
+ - lib/fastlane/plugin/apkgo/actions/upload_to_apkgo_action.rb
107
+ - lib/fastlane/plugin/apkgo/helper/apkgo_helper.rb
108
+ - lib/fastlane/plugin/apkgo/version.rb
109
+ homepage: https://github.com/KevinGong2013/fastlane-plugin-apkgo
110
+ licenses:
111
+ - MIT
112
+ metadata:
113
+ rubygems_mfa_required: 'true'
114
+ post_install_message:
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '2.6'
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 3.4.20
130
+ signing_key:
131
+ specification_version: 4
132
+ summary: Publish an APK to Chinese app stores via the apkgo cloud Open API
133
+ test_files: []