pod-binary-pack 1.0.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.
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+ require 'shellwords'
3
+ require_relative 'push'
4
+
5
+ module Build
6
+ class GitHelper
7
+
8
+ # @return [Boolean] 返回是否有变更
9
+ def GitHelper.status(path)
10
+ current = Dir.pwd
11
+ Dir.chdir(path)
12
+ Build.log.debug("获取代码状态(#{File.basename(path)}): git status")
13
+ res = %x(git status)
14
+ Dir.chdir(current)
15
+ if res.include? 'nothing to commit, working tree clean'
16
+ Build.log.debug("当前无变更")
17
+ return false
18
+ end
19
+ true
20
+ end
21
+
22
+ def GitHelper.add_stag(path)
23
+ command = "git add ."
24
+ GitHelper.execute(path, command, true)
25
+ end
26
+
27
+ # 仅暂存指定文件(避免把构建产物/临时文件一并提交)
28
+ def GitHelper.add_files(path, files)
29
+ list = Array(files).map { |f| Shellwords.escape(f) }.join(' ')
30
+ GitHelper.execute(path, "git add -- #{list}", true)
31
+ end
32
+
33
+ # 指定文件相对HEAD是否有改动(含修改/新增/删除)
34
+ # @return [Boolean]
35
+ def GitHelper.file_changed?(path, file)
36
+ res = GitHelper.capture(path, "git status --porcelain -- #{Shellwords.escape(file)}")
37
+ !res.strip.empty?
38
+ end
39
+
40
+ def GitHelper.commit(path, commit_msg = nil)
41
+ commit_msg = commit_msg ? commit_msg : "none message"
42
+ # commit_msg含上游committer/commit信息(任意字符),必须转义避免引号断裂命令或注入
43
+ command = "git commit -m #{Shellwords.escape(commit_msg)}"
44
+ GitHelper.execute(path, command, true)
45
+ end
46
+
47
+ def GitHelper.pull(path)
48
+ command = "git pull"
49
+ GitHelper.execute(path, command, true)
50
+ end
51
+
52
+ def GitHelper.push(path, branch)
53
+ command = "git push origin #{Shellwords.escape(branch)}"
54
+ GitHelper.execute(path, command, true)
55
+ end
56
+
57
+ def GitHelper.push_f(path, branch)
58
+ command = "git push origin -f #{Shellwords.escape(branch)}"
59
+ GitHelper.execute(path, command, true)
60
+ end
61
+
62
+ def GitHelper.reset_hard(path)
63
+ command = "git reset --hard"
64
+ GitHelper.execute(path, command, true)
65
+ end
66
+
67
+ def GitHelper.fetch(path)
68
+ command = "git fetch"
69
+ GitHelper.execute(path, command, true)
70
+ end
71
+
72
+ def GitHelper.checkout(path, checkout_branch)
73
+ command = "git checkout #{Shellwords.escape(checkout_branch)}"
74
+ GitHelper.execute(path, command, true)
75
+ end
76
+
77
+ def GitHelper.tag_delete_local(path, tag)
78
+ command = "git tag -d #{Shellwords.escape(tag)}"
79
+ GitHelper.execute(path, command, false)
80
+ end
81
+
82
+ def GitHelper.tag_delete_origin(path, tag)
83
+ command = "git push origin :refs/tags/#{Shellwords.escape(tag)}"
84
+ GitHelper.execute(path, command, true)
85
+ end
86
+
87
+ def GitHelper.tag_local(path, tag)
88
+ command = "git tag #{Shellwords.escape(tag)}"
89
+ GitHelper.execute(path, command, true)
90
+ end
91
+
92
+ def GitHelper.tag_origin(path, tag)
93
+ command = "git push origin #{Shellwords.escape(tag)}"
94
+ GitHelper.execute(path, command, true)
95
+ end
96
+
97
+ def GitHelper.push_tags(path)
98
+ command = "git push origin --tags"
99
+ GitHelper.execute(path, command, true)
100
+ end
101
+
102
+ # 远端是否存在指定分支
103
+ # @return [Boolean]
104
+ def GitHelper.remote_branch?(path, branch)
105
+ res = GitHelper.capture(path, "git ls-remote --heads origin #{Shellwords.escape(branch)}")
106
+ !res.strip.empty?
107
+ end
108
+
109
+ # 解析某个引用的commit sha,解析失败返回nil
110
+ # @return [String, nil]
111
+ def GitHelper.revision(path, ref)
112
+ res = GitHelper.capture(path, "git rev-parse --verify #{Shellwords.escape(ref)}^{commit} 2>/dev/null")
113
+ res = res.strip
114
+ res.empty? ? nil : res
115
+ end
116
+
117
+ # 判断ancestor是否为descendant的祖先(用于fast-forward可行性判断)
118
+ # @return [Boolean]
119
+ def GitHelper.ancestor?(path, ancestor, descendant)
120
+ GitHelper.run_bool(path, "git merge-base --is-ancestor #{Shellwords.escape(ancestor)} #{Shellwords.escape(descendant)}")
121
+ end
122
+
123
+ # 基于远端分支创建/重置本地分支并切换: git checkout -B branch origin/branch
124
+ def GitHelper.checkout_tracking(path, branch)
125
+ command = "git checkout -B #{Shellwords.escape(branch)} origin/#{Shellwords.escape(branch)}"
126
+ GitHelper.execute(path, command, true)
127
+ end
128
+
129
+ # fast-forward合并到指定引用,不能ff时命令失败
130
+ def GitHelper.merge_ff_only(path, ref)
131
+ command = "git merge --ff-only #{Shellwords.escape(ref)}"
132
+ GitHelper.execute(path, command, true)
133
+ end
134
+
135
+ # 把当前HEAD推送为远端指定分支名(用于远端目标分支不存在时创建)
136
+ def GitHelper.push_head_as(path, remote_branch)
137
+ command = "git push origin HEAD:#{Shellwords.escape(remote_branch)}"
138
+ GitHelper.execute(path, command, true)
139
+ end
140
+
141
+ # ===== http归档git化专用(http归档组件在fork阶段就地建成全新git仓库) =====
142
+
143
+ # 初始化全新git仓库并写入本地提交者配置,不依赖机器全局git配置;
144
+ # 不指定初始分支名(兼容低版本git无init -b),推送时用 HEAD:master 显式对齐远端分支名
145
+ def GitHelper.init_repo(path)
146
+ GitHelper.execute(path, "git init", true)
147
+ GitHelper.execute(path, "git config user.name jenkins", true)
148
+ GitHelper.execute(path, "git config user.email jenkins@jenkins.com", true)
149
+ end
150
+
151
+ # 全新仓库添加origin远程地址
152
+ def GitHelper.remote_add(path, name, url)
153
+ command = "git remote add #{name} #{Shellwords.escape(url)}"
154
+ GitHelper.execute(path, command, true)
155
+ end
156
+
157
+ # 当前HEAD强推为远端master分支(全新git化仓库推送,不依赖本地分支名)
158
+ def GitHelper.push_head_master_f(path)
159
+ command = "git push origin -f HEAD:master"
160
+ GitHelper.execute(path, command, true)
161
+ end
162
+
163
+ # 强推指定tag(同名tag指向新commit时的覆盖推送)
164
+ def GitHelper.tag_origin_f(path, tag)
165
+ command = "git push origin -f #{tag}"
166
+ GitHelper.execute(path, command, true)
167
+ end
168
+
169
+ # 获取远端仓库全部tag名(ls-remote直查远端,不依赖本地克隆,剥离 ^{} 剥离引用)。
170
+ # 仓库不存在/不可达时返回nil,空仓库返回[]
171
+ # @return [Array<String>, nil]
172
+ def GitHelper.remote_tags(url)
173
+ res = %x(git ls-remote --tags #{Shellwords.escape(url)} 2>/dev/null)
174
+ return nil unless $?.success?
175
+
176
+ res.lines.map do |line|
177
+ ref = line.split("\t")[1].to_s.chomp
178
+ ref.sub(/\Arefs\/tags\//, '').sub(/\^\{\}\z/, '')
179
+ end.reject(&:empty?).uniq
180
+ end
181
+
182
+ private
183
+ # 执行命令并返回标准输出内容,不改变调用方目录
184
+ def GitHelper.capture(path, command)
185
+ current = Dir.pwd
186
+ Dir.chdir(path)
187
+ Build.log.debug("git命令(#{File.basename(path)}):#{command}")
188
+ res = %x(#{command})
189
+ Dir.chdir(current)
190
+ res
191
+ end
192
+
193
+ # 执行命令并返回布尔结果,不因失败退出进程
194
+ # @return [Boolean]
195
+ def GitHelper.run_bool(path, command)
196
+ current = Dir.pwd
197
+ Dir.chdir(path)
198
+ Build.log.debug("git命令(#{File.basename(path)}):#{command}")
199
+ ok = system(command)
200
+ Dir.chdir(current)
201
+ ok == true
202
+ end
203
+
204
+ private
205
+ def GitHelper.execute(path, command, can_raise)
206
+ current = Dir.pwd
207
+ Dir.chdir(path)
208
+
209
+ Build.log.debug("git命令(#{File.basename(path)}):#{command}")
210
+ res = system command
211
+
212
+ if can_raise and not res
213
+ pusher = Push.instance
214
+ error = "失败: #{command}"
215
+ pusher.push("❌ 失败", error)
216
+ Process.exit(-1)
217
+ end
218
+
219
+ Dir.chdir(current)
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,425 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'repo'
3
+ require_relative 'config'
4
+ require_relative 'hosting'
5
+ require 'git'
6
+ require 'json'
7
+ require 'fileutils'
8
+ require 'digest'
9
+ require 'shellwords'
10
+ require 'singleton'
11
+
12
+ # https://rubydoc.info/gems/git/2.3.0/Git/Base
13
+
14
+ module Build
15
+ class GithubFork
16
+ include Singleton
17
+
18
+ # @return [String] 仓库本机路径
19
+ attr_accessor :path
20
+
21
+ # @return [String] 组件名
22
+ attr_accessor :pod_name
23
+
24
+ # @return [String] fork前对应的github地址
25
+ attr_accessor :github_url
26
+
27
+ # @return [String] fork后对应的代码托管平台地址(Gitee/GitLab)
28
+ attr_accessor :source_repo_url
29
+
30
+ # @return [Git] 当前仓库 clone完毕后有值
31
+ attr_accessor :repo
32
+
33
+ # http归档信息(source.http组件);git源组件为nil
34
+ # @return [Hash, nil] {url:, type:, sha256:, sha1:, flatten:, version:}
35
+ attr_accessor :archive
36
+
37
+ # 下载到本地的podspec.json路径(播种进归档目录时使用)
38
+ attr_accessor :podspec_json_path
39
+
40
+ # 是否强制覆盖(镜像仓同名tag指向新commit时允许覆盖推送)
41
+ attr_accessor :force_cover
42
+
43
+ def setup(path, pod_name, github_url, source_repo_url, archive: nil, podspec_json_path: nil, force_cover: false)
44
+ self.path = path
45
+ self.pod_name = pod_name
46
+ self.github_url = github_url
47
+ self.source_repo_url = source_repo_url
48
+ self.archive = archive
49
+ self.podspec_json_path = podspec_json_path
50
+ self.force_cover = force_cover == true
51
+ end
52
+
53
+ def fork_if_need(repo_cocoapods_source)
54
+ if archive
55
+ fork_http_archive(repo_cocoapods_source)
56
+ else
57
+ download_github
58
+ check_can_fork(repo_cocoapods_source)
59
+ create_if_need
60
+ upload_source
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ # ===== http归档组件流程:下载归档 → 解压 → 播种podspec → 建镜像仓 → 防呆校验 → 就地git化推送 =====
67
+ # 背景:sqlite3等组件的podspec source为{http: 官方归档},没有上游git仓可克隆,
68
+ # 无法走常规fork流程。将归档内容git化成一个镜像仓(等价正常源码仓库)后,
69
+ # 发布podspec引用{git: 镜像仓, tag: 版本号},下游全部复用现有机制
70
+ def fork_http_archive(repo_cocoapods_source)
71
+ download_archive
72
+ seed_podspec
73
+ create_if_need(archive[:url])
74
+ check_published_refs_on_mirror(repo_cocoapods_source)
75
+ gitify_and_upload
76
+ end
77
+
78
+ # 下载归档文件:原始地址优先,github.com归档自动追加代理镜像候选,失败切换下一个
79
+ def download_archive
80
+ Build.log.step("下载http归档(#{File.basename(self.path)})")
81
+ # 上次失败可能残留半成品目录/文件,先清理
82
+ FileUtils.rm_rf(self.path)
83
+ FileUtils.rm_rf(archive_tmp_file)
84
+ FileUtils.mkdir_p(self.path)
85
+
86
+ urls = build_archive_urls
87
+ downloaded = false
88
+ urls.each_with_index do |url, index|
89
+ begin
90
+ Build.log.info("下载归档(#{index + 1}/#{urls.size} #{url})...")
91
+ # -f: HTTP错误码直接失败(避免把错误页存成文件) -L: 跟随重定向 -sS: 静默但保留错误输出
92
+ command = "curl -fsSL --retry 2 --connect-timeout 15 --max-time 600 " \
93
+ "-o #{Shellwords.escape(archive_tmp_file)} #{Shellwords.escape(url)}"
94
+ raise 'curl命令执行失败' unless system(command)
95
+ raise '归档文件未生成或为空' unless File.exist?(archive_tmp_file) && File.size(archive_tmp_file) > 0
96
+
97
+ downloaded = true
98
+ break
99
+ rescue => exception
100
+ Build.log.detail("归档下载失败:#{exception.message},切换下一个下载源")
101
+ FileUtils.rm_f(archive_tmp_file)
102
+ end
103
+ end
104
+
105
+ unless downloaded
106
+ Build.log.error("所有归档下载源均失败,请检查网络后重试:#{archive[:url]}")
107
+ Process.exit(-1)
108
+ end
109
+
110
+ verify_archive_digest
111
+ extract_archive
112
+ FileUtils.rm_f(archive_tmp_file)
113
+ Build.log.detail("归档解压完毕(#{Dir.children(self.path).size}个顶层条目)")
114
+ end
115
+
116
+ # 归档文件临时存放路径(与解压目录同级,解压后删除)
117
+ def archive_tmp_file
118
+ "#{self.path}.archive"
119
+ end
120
+
121
+ # 归档下载候选地址:原始地址优先;github.com归档自动追加代理镜像
122
+ # @return [Array<String>]
123
+ def build_archive_urls
124
+ urls = [archive[:url]]
125
+ if archive[:url] =~ %r{\Ahttps?://(www\.)?github\.com/}
126
+ urls << "https://ghproxy.net/#{archive[:url]}"
127
+ urls << "https://gh-proxy.com/#{archive[:url]}"
128
+ end
129
+ urls.uniq
130
+ end
131
+
132
+ # 归档摘要校验:声明了sha256/sha1时与实际计算值比对,不一致直接终止;
133
+ # 实际sha256始终记录到GithubHelper(无论是否声明),供发布podspec的build_metadata溯源
134
+ def verify_archive_digest
135
+ actual_sha256 = Digest::SHA256.file(archive_tmp_file).hexdigest
136
+ GithubHelper.instance.archive_sha_actual = actual_sha256
137
+
138
+ if archive[:sha256].to_s.strip.empty? && archive[:sha1].to_s.strip.empty?
139
+ Build.log.detail("归档未声明摘要,已计算sha256: #{actual_sha256}")
140
+ return
141
+ end
142
+
143
+ if !archive[:sha256].to_s.strip.empty?
144
+ if actual_sha256 != archive[:sha256].strip.downcase
145
+ fail_digest('sha256', archive[:sha256], actual_sha256)
146
+ end
147
+ elsif !archive[:sha1].to_s.strip.empty?
148
+ actual_sha1 = Digest::SHA1.file(archive_tmp_file).hexdigest
149
+ if actual_sha1 != archive[:sha1].strip.downcase
150
+ fail_digest('sha1', archive[:sha1], actual_sha1)
151
+ end
152
+ end
153
+ Build.log.detail("归档摘要校验通过: sha256=#{actual_sha256}")
154
+ end
155
+
156
+ def fail_digest(type, declared, actual)
157
+ Build.log.error("归档#{type}校验失败:声明#{declared},实际#{actual},归档内容可能被篡改或声明有误")
158
+ Process.exit(-1)
159
+ end
160
+
161
+ # 按归档类型解压到目标目录(保留归档原始顶层目录结构,flatten=true时上移)
162
+ def extract_archive
163
+ Build.log.detail("解压归档(#{archive[:type]})到#{File.basename(self.path)}")
164
+ escaped_file = Shellwords.escape(archive_tmp_file)
165
+ escaped_dir = Shellwords.escape(self.path)
166
+ command = case archive[:type]
167
+ when 'zip' then "unzip -q -o #{escaped_file} -d #{escaped_dir}"
168
+ when 'tar.gz', 'tgz' then "tar -xzf #{escaped_file} -C #{escaped_dir}"
169
+ when 'tar.bz2', 'tbz', 'tbz2' then "tar -xjf #{escaped_file} -C #{escaped_dir}"
170
+ when 'tar.xz', 'txz' then "tar -xJf #{escaped_file} -C #{escaped_dir}"
171
+ else
172
+ Build.log.error("不支持的归档类型: #{archive[:type]}")
173
+ Process.exit(-1)
174
+ end
175
+ unless system(command)
176
+ Build.log.error("归档解压失败: #{command}")
177
+ Process.exit(-1)
178
+ end
179
+
180
+ # 清理macOS压缩包自带的资源叉目录/文件,避免污染flatten判定与镜像仓内容
181
+ if archive[:type] == 'zip'
182
+ FileUtils.rm_rf(File.join(self.path, '__MACOSX'))
183
+ Dir.glob(File.join(self.path, '**', '.DS_Store'), File::FNM_DOTMATCH).each { |p| FileUtils.rm_f(p) }
184
+ end
185
+
186
+ flatten_archive_root if archive[:flatten]
187
+ end
188
+
189
+ # flatten语义:归档仅有一个顶层目录时,把其内容上移到根目录
190
+ # (与CocoaPods http下载flatten行为一致,便于source_files直接匹配根目录)
191
+ def flatten_archive_root
192
+ entries = Dir.children(self.path).reject { |e| e == '.DS_Store' }
193
+ return unless entries.size == 1 && File.directory?(File.join(self.path, entries.first))
194
+
195
+ top_dir = File.join(self.path, entries.first)
196
+ Dir.children(top_dir).each do |child|
197
+ FileUtils.mv(File.join(top_dir, child), File.join(self.path, child))
198
+ end
199
+ FileUtils.rmdir(top_dir)
200
+ Build.log.detail("flatten=true,已将归档顶层目录#{entries.first}内容上移到根目录")
201
+ end
202
+
203
+ # 播种podspec:把从Specs下载的podspec.json放入归档根目录。
204
+ # 归档内不携带podspec,git化后镜像仓自身携带才能让克隆侧定位到podspec
205
+ # (source_podspec_entry扫描与LocalDependencies的path集成均依赖它)
206
+ def seed_podspec
207
+ unless podspec_json_path && File.exist?(podspec_json_path)
208
+ Build.log.error("未找到已下载的podspec文件,无法播种到归档目录: #{podspec_json_path}")
209
+ Process.exit(-1)
210
+ end
211
+
212
+ dest = File.join(self.path, "#{self.pod_name}.podspec.json")
213
+ FileUtils.cp(podspec_json_path, dest)
214
+ Build.log.detail("已播种podspec: #{File.basename(dest)}")
215
+ end
216
+
217
+ # 防呆校验:已发布源码spec引用的版本tag必须仍存在于镜像仓远端。
218
+ # http归档组件每次构建都是全新单提交历史,无法像git fork那样用上游全量历史校验,
219
+ # 改为ls-remote直查镜像仓远端refs——可拦截"镜像仓被删除重建导致历史tag丢失"
220
+ # 后静默发布引用失效spec的问题(旧版本切源码时会拉不到代码)
221
+ def check_published_refs_on_mirror(repo_cocoapods_source)
222
+ versions = repo_cocoapods_source.pod_name_versions(self.pod_name)
223
+ return unless versions
224
+
225
+ tag_refs = versions.map { |version| published_source_ref(repo_cocoapods_source.path, version) }
226
+ .compact
227
+ .select { |ref_type, _| ref_type == 'tag' }
228
+ .map { |_, ref_name| ref_name }
229
+ .uniq
230
+ return if tag_refs.empty?
231
+
232
+ Build.log.step("校验已发布版本tag在镜像仓仍存在(#{self.source_repo_url})")
233
+ remote_tags = GitHelper.remote_tags(self.source_repo_url.ssh_to_http)
234
+ if remote_tags.nil?
235
+ Build.log.detail("镜像仓暂不可达或尚未创建,跳过已发布tag校验(后续推送若失败会直接报错)")
236
+ return
237
+ end
238
+
239
+ missing = tag_refs - remote_tags
240
+ if missing.any?
241
+ Build.log.error("镜像仓#{self.source_repo_url}缺失已发布版本引用的tag:#{missing.join('、')}," \
242
+ "镜像仓可能被删除重建,旧版本切源码将拉不到代码,请先恢复历史tag或使用强制覆盖后重新git化")
243
+ Process.exit(-1)
244
+ end
245
+ Build.log.detail("已发布版本tag校验通过,共#{tag_refs.size}个")
246
+ end
247
+
248
+ # 就地git化并推送镜像仓:
249
+ # git init(本地提交者配置兜底) → 提交原始归档内容 → 打版本tag → 强推master → 推tag
250
+ # 只推送master与当前版本tag,禁用--mirror/--prune,保证不影响镜像仓里其它历史tag
251
+ def gitify_and_upload
252
+ Build.log.step("归档git化并推送镜像仓(#{File.basename(self.path)})")
253
+ tag = archive[:version]
254
+
255
+ GitHelper.init_repo(self.path)
256
+ GitHelper.add_stag(self.path)
257
+ digest = GithubHelper.instance.archive_sha_actual
258
+ commit_msg = "#{self.pod_name} #{tag} 官方归档导入(地址: #{archive[:url]} sha256: #{digest})"
259
+ GitHelper.commit(self.path, commit_msg)
260
+ GitHelper.tag_local(self.path, tag)
261
+ GitHelper.remote_add(self.path, 'origin', self.source_repo_url)
262
+ GitHelper.push_head_master_f(self.path)
263
+
264
+ # tag推送策略:归档内容固定,镜像仓已有同名tag时默认跳过;--force-cover时强制覆盖到新commit
265
+ existing_tags = GitHelper.remote_tags(self.source_repo_url.ssh_to_http) || []
266
+ if existing_tags.include?(tag)
267
+ if self.force_cover
268
+ GitHelper.tag_origin_f(self.path, tag)
269
+ Build.log.info("镜像仓tag#{tag}已存在,force_cover=true 已强制覆盖")
270
+ else
271
+ Build.log.info("镜像仓tag#{tag}已存在且归档内容固定,跳过tag推送")
272
+ end
273
+ else
274
+ GitHelper.tag_origin(self.path, tag)
275
+ end
276
+
277
+ Build.log.detail("归档git化推送完毕(master + tag #{tag})")
278
+ end
279
+
280
+ # ===== 以下为git源fork组件的原有流程 =====
281
+
282
+ def download_github
283
+ repo_path = Pathname(path)
284
+ Build.log.step("fork源码(#{File.basename(self.path)})")
285
+
286
+ # 克隆候选地址:优先原始地址直连,失败再依次切换镜像重试
287
+ clone_urls = build_clone_urls
288
+ cloned = false
289
+ clone_urls.each_with_index do |clone_url, index|
290
+ begin
291
+ Build.log.info("下载源码文件(#{index + 1}/#{clone_urls.size} #{clone_url})...")
292
+ # 上次克隆失败可能残留半成品目录,克隆前先清理,避免目录已存在导致再次克隆失败
293
+ FileUtils.rm_rf(repo_path)
294
+ # 单次克隆设置120秒超时,避免镜像不可达时长时间卡住打包流程
295
+ Git.clone(clone_url, repo_path, timeout: 120)
296
+ cloned = true
297
+ break
298
+ rescue => exception
299
+ Build.log.detail("源码下载失败:#{exception.message},切换下一个下载源")
300
+ end
301
+ end
302
+
303
+ unless cloned
304
+ Build.log.error("所有源码下载源均克隆失败,请检查网络后重试:#{self.github_url}")
305
+ Process.exit(-1)
306
+ end
307
+ Build.log.detail("上游源码克隆完毕")
308
+
309
+ Build.log.detail("检出所有分支")
310
+ self.repo = Git.open(repo_path)
311
+ # git gem 5.x 新API:branch_list 同时返回本地和远程跟踪分支(BranchInfo)。
312
+ # 遍历每个分支并检出:远程跟踪分支需先在本地创建同名分支,
313
+ # 这样后续 push --all 才能把上游全部分支镜像到目标仓库
314
+ self.repo.branch_list.each do |branch_info|
315
+ # 跳过 origin/HEAD 这类指向默认分支的符号引用,它不是真实分支
316
+ next if branch_info.symref?
317
+
318
+ branch_name = branch_info.short_name
319
+ if branch_info.remote? && !self.repo.local_branch?(branch_name)
320
+ self.repo.branch_new(branch_name, branch_info.refname)
321
+ end
322
+ self.repo.checkout(branch_name)
323
+ end
324
+
325
+ self.repo.config_set('remote.origin.url', self.source_repo_url)
326
+ end
327
+
328
+ # 构造源码克隆候选地址:原始地址优先,其后依次为 kkgithub 域名替换、gh.llkk.cc 前缀代理
329
+ # @return [Array<String>]
330
+ def build_clone_urls
331
+ if self.github_url.start_with?("http")
332
+ http_url = self.github_url
333
+ else
334
+ # SSH 地址镜像不支持,先转成 https 形式再拼镜像
335
+ http_url = self.github_url.ssh_to_http.sub(/\Ahttp:/, "https:")
336
+ http_url = "#{http_url}.git" unless http_url.end_with?(".git")
337
+ end
338
+
339
+ [
340
+ self.github_url,
341
+ http_url.gsub("github.com", "kkgithub.com"),
342
+ "https://gh.llkk.cc/#{http_url}"
343
+ ].uniq
344
+ end
345
+
346
+ # fork前校验:源码spec仓里每个已发布版本的podspec,其source引用的tag/branch/commit
347
+ # 必须在上游仓库真实存在,避免镜像force推送后老版本引用失效。
348
+ # 注意:不能拿版本号(如1.1.0)直接与tag名比较——三方库上游tag普遍带v前缀(如v1.1.0),
349
+ # 必须以已发布podspec里source声明的实际引用为准。
350
+ # 只校验源码spec仓:二进制spec的source指向二进制组件仓,与GitHub上游无关,不在此校验。
351
+ def check_can_fork(repo_cocoapods)
352
+ versions = repo_cocoapods.pod_name_versions(self.pod_name)
353
+ return unless versions
354
+
355
+ Build.log.step("校验已发布版本引用是否都存在于上游仓库(#{self.github_url})")
356
+ missing = []
357
+ versions.each do |version|
358
+ ref_type, ref_name = published_source_ref(repo_cocoapods.path, version)
359
+ if ref_type.nil?
360
+ Build.log.detail("版本#{version}未找到可校验的podspec source(tag/branch/commit),跳过")
361
+ next
362
+ end
363
+
364
+ if ref_exist?(ref_name)
365
+ Build.log.detail("版本#{version} 引用 #{ref_type} #{ref_name}:上游存在")
366
+ else
367
+ missing << "#{version}(#{ref_type}:#{ref_name})"
368
+ end
369
+ end
370
+
371
+ if missing.length > 0
372
+ Build.log.error("fork失败,以下已发布版本引用的tag/分支/commit在上游仓库#{self.github_url}中不存在:#{missing.join('、')},请手动处理后再打包。")
373
+ Process.exit(-1)
374
+ end
375
+ Build.log.detail("已发布版本引用校验通过,共#{versions.length}个版本")
376
+ end
377
+
378
+ # 读取源码spec仓中某已发布版本podspec.json声明的源码引用
379
+ # @return [Array(String, String), nil] [tag/branch/commit, 引用名];无podspec或source非git引用时返回nil
380
+ def published_source_ref(spec_root, version)
381
+ json_path = File.join(spec_root, self.pod_name, version, "#{self.pod_name}.podspec.json")
382
+ unless File.exist?(json_path)
383
+ # 兼容非常规命名:版本目录下唯一的podspec.json
384
+ candidates = Dir.glob(File.join(spec_root, self.pod_name, version, '*.podspec.json'))
385
+ json_path = candidates.first if candidates.length == 1
386
+ end
387
+ return nil unless json_path && File.exist?(json_path)
388
+
389
+ source = JSON.parse(File.read(json_path))['source'] rescue nil
390
+ return nil unless source.is_a?(Hash)
391
+
392
+ if !source['tag'].to_s.empty?
393
+ ['tag', source['tag'].to_s]
394
+ elsif !source['branch'].to_s.empty?
395
+ ['branch', source['branch'].to_s]
396
+ elsif !source['commit'].to_s.empty?
397
+ ['commit', source['commit'].to_s]
398
+ end
399
+ end
400
+
401
+ # 校验某个git引用(tag名/分支名/commit号)在克隆下来的上游仓库中是否存在。
402
+ # 全量克隆包含上游所有历史,revparse 对tag/branch/commit均可解析;不存在时git gem抛FailedError
403
+ def ref_exist?(ref_name)
404
+ self.repo.revparse(ref_name)
405
+ true
406
+ rescue Git::FailedError
407
+ false
408
+ end
409
+
410
+ def create_if_need(description = self.github_url)
411
+ provider = Hosting.provider_for(self.source_repo_url)
412
+ # fork源码镜像仓不存在时自动创建为私有仓库(固定私有)
413
+ provider.create_project_if_need(self.pod_name, self.source_repo_url.ssh_to_http, description: description)
414
+ end
415
+
416
+ def upload_source
417
+ Build.log.detail("推送并覆盖所有分支和标签(#{self.source_repo_url})")
418
+ # git gem 5.x: push(remote, refspec, opts),all/tags/force 在第三个参数
419
+ self.repo.push('origin', nil, {:all => true, :force => true})
420
+ self.repo.push('origin', nil, {:tags => true, :force => true})
421
+ Build.log.detail("分支和标签推送完毕")
422
+ end
423
+
424
+ end
425
+ end