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 +7 -0
- data/LICENSE +21 -0
- data/README.md +115 -0
- data/lib/fastlane/plugin/apkgo/actions/upload_to_apkgo_action.rb +251 -0
- data/lib/fastlane/plugin/apkgo/helper/apkgo_helper.rb +172 -0
- data/lib/fastlane/plugin/apkgo/version.rb +5 -0
- data/lib/fastlane/plugin/apkgo.rb +16 -0
- metadata +133 -0
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
|
+
[](https://rubygems.org/gems/fastlane-plugin-apkgo)
|
|
4
|
+
[](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,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: []
|