mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 16:07:29 +00:00
Publish an immutable channel snapshot with each release and advance a fixed release pointer only after public assets pass verification. Check the exact platform package before notifying clients. Co-authored-by: Codex <noreply@openai.com>
171 lines
7.0 KiB
Ruby
171 lines
7.0 KiB
Ruby
require 'base64'
|
|
require 'digest'
|
|
require 'uri'
|
|
require_relative 'release-metadata'
|
|
|
|
# 通道快照随版本封存,固定 Release 只记录已公开快照和可恢复的待发布摘要
|
|
module PublishedChannels
|
|
POINTER = 'update-index'
|
|
LIMIT = 128 * 1024
|
|
|
|
def self.advance(document, version)
|
|
version = ReleaseMetadata.version(version)
|
|
major, = version.split('.', 2)
|
|
core, stage = version.split('+', 2).first.split('-', 2)
|
|
channels = (document.fetch('published')[major] ||= {})
|
|
if stage&.start_with?('exp.')
|
|
channels = (channels['exp'] ||= {})
|
|
keys = [core]
|
|
else
|
|
stages = %w[stable rc beta alpha]
|
|
keys = stages.drop(stages.index(stage ? stage.split('.').first : 'stable'))
|
|
end
|
|
incoming = ReleaseMetadata.sort_key(version)
|
|
changed = false
|
|
keys.each do |key|
|
|
current = channels[key]
|
|
if current
|
|
comparison = incoming <=> ReleaseMetadata.sort_key(current)
|
|
raise "Conflicting release identities: #{current}, #{version}" if comparison.zero? && current != version
|
|
next unless comparison.positive?
|
|
end
|
|
channels[key] = version
|
|
changed = true
|
|
end
|
|
changed
|
|
end
|
|
|
|
class Publisher
|
|
def api(endpoint, method: 'GET', data: nil, optional: false)
|
|
args = ['gh', 'api', endpoint, '--method', method]
|
|
args.concat(%w[--input -]) if data
|
|
out, error, status = Open3.capture3(*args, stdin_data: data ? JSON.generate(data) : '')
|
|
return nil if optional && !status.success? && error.include?('(HTTP 404)')
|
|
raise "Cannot access #{endpoint}: #{error.strip}" unless status.success?
|
|
JSON.parse(out)
|
|
end
|
|
|
|
def initialize(repository)
|
|
@repository = repository
|
|
@base = "repos/#{repository}"
|
|
release = api("#{@base}/releases/tags/#{POINTER}", optional: true)
|
|
if release
|
|
raise 'Invalid update-index release' unless release.fetch('tag_name') == POINTER &&
|
|
release.fetch('prerelease') && !release.fetch('draft')
|
|
@id = release.fetch('id')
|
|
@state = JSON.parse(release.fetch('body'))
|
|
@state.fetch('current')
|
|
else
|
|
@state = { 'current' => nil }
|
|
end
|
|
end
|
|
|
|
def save(current:, pending: nil)
|
|
state = { 'current' => current }
|
|
state['pending'] = pending if pending
|
|
body = { body: JSON.generate(state), make_latest: 'false' }
|
|
if @id
|
|
api("#{@base}/releases/#{@id}", method: 'PATCH', data: body)
|
|
else
|
|
release = api("#{@base}/releases", method: 'POST', data: body.merge(
|
|
tag_name: POINTER, name: '更新通道索引', target_commitish: 'main', prerelease: true
|
|
))
|
|
@id = release.fetch('id')
|
|
end
|
|
@state = state
|
|
end
|
|
|
|
def release(version)
|
|
api("#{@base}/releases/tags/v#{ReleaseMetadata.version(version)}", optional: true)
|
|
end
|
|
|
|
def verify_assets(version, release, index:)
|
|
tag = "v#{version}"
|
|
raise "Release is not public or tag differs: #{tag}" unless release && release.fetch('tag_name') == tag &&
|
|
!release.fetch('draft') && release.fetch('prerelease') == ReleaseMetadata.prerelease?(version)
|
|
data = api("#{@base}/contents/release/platforms.yaml?ref=#{URI.encode_www_form_component(tag)}")
|
|
platforms = YAML.safe_load(Base64.decode64(data.fetch('content'))).fetch('platforms')
|
|
raise "Missing platforms for #{tag}" if platforms.empty?
|
|
expected = platforms.map { |p| ReleaseMetadata.archive_name(version, p) }
|
|
expected.concat(%w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json])
|
|
expected << 'version.yaml' if index
|
|
assets = release.fetch('assets').select { |a| a.fetch('state') == 'uploaded' && a.fetch('size').positive? }.to_h { |a| [a.fetch('name'), a] }
|
|
missing = expected - assets.keys
|
|
raise "Incomplete release #{tag}: #{missing.join(', ')}" unless missing.empty?
|
|
assets
|
|
end
|
|
|
|
def download(version)
|
|
content, error, status = Open3.capture3('gh', 'release', 'download', "v#{version}", '--repo', @repository,
|
|
'--pattern', 'version.yaml', '--output', '-')
|
|
raise "Cannot download v#{version} snapshot: #{error.strip}" unless status.success?
|
|
content
|
|
end
|
|
|
|
def read_snapshot(version, asset)
|
|
raise 'Invalid version.yaml size' unless (1..LIMIT).cover?(asset.fetch('size'))
|
|
content = download(version)
|
|
digest = Digest::SHA256.hexdigest(content)
|
|
raise "Snapshot size or digest differs for v#{version}" unless content.bytesize == asset.fetch('size') && asset.fetch('digest') == "sha256:#{digest}"
|
|
doc = YAML.safe_load(content)
|
|
raise 'Invalid channel snapshot' unless doc.fetch('version') == version && doc.fetch('published').is_a?(Hash)
|
|
[doc, digest]
|
|
end
|
|
|
|
def previous
|
|
if (current = @state.fetch('current'))
|
|
assets = verify_assets(current, release(current), index: true)
|
|
return read_snapshot(current, assets.fetch('version.yaml')).first
|
|
end
|
|
# 首次接入时从实际公开的稳定版引导;没有历史 Release 的仓库从空表开始
|
|
doc = { 'published' => {} }
|
|
latest = api("#{@base}/releases/latest", optional: true)
|
|
if latest
|
|
version = ReleaseMetadata.version(latest.fetch('tag_name'))
|
|
verify_assets(version, latest, index: false)
|
|
PublishedChannels.advance(doc, version)
|
|
end
|
|
doc
|
|
end
|
|
|
|
# 调用方持有同一仓库的发布锁;快照必须在取锁后生成,不能在并行构建中预先合并
|
|
def prepare(version, directory)
|
|
if (pending = @state.dig('pending', 'version')) && (old = release(pending)) && !old.fetch('draft')
|
|
finish(pending)
|
|
end
|
|
existing = release(version)
|
|
raise "Release v#{version} already public; use index-only recovery" if existing && !existing.fetch('draft')
|
|
doc = previous
|
|
doc['version'] = version
|
|
PublishedChannels.advance(doc, version)
|
|
content = YAML.dump(doc)
|
|
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
|
|
digest = Digest::SHA256.hexdigest(content)
|
|
File.write(File.join(directory, 'version.yaml'), content)
|
|
File.open(File.join(directory, 'checksums.txt'), 'a') { |file| file.puts "#{digest} version.yaml" }
|
|
save(current: @state.fetch('current'), pending: { 'version' => version, 'sha256' => digest })
|
|
end
|
|
|
|
def finish(version)
|
|
assets = verify_assets(version, release(version), index: true)
|
|
_, digest = read_snapshot(version, assets.fetch('version.yaml'))
|
|
return if @state.fetch('current') == version
|
|
unless @state['pending'] == { 'version' => version, 'sha256' => digest }
|
|
raise "v#{version} is not the reserved snapshot; refusing to overwrite another publication"
|
|
end
|
|
save(current: version)
|
|
end
|
|
end
|
|
end
|
|
|
|
if $PROGRAM_NAME == __FILE__
|
|
mode, raw = ARGV
|
|
version = ReleaseMetadata.version(raw || '')
|
|
publisher = PublishedChannels::Publisher.new(ENV.fetch('GITHUB_REPOSITORY'))
|
|
case mode
|
|
when 'prepare' then publisher.prepare(version, 'dist/release')
|
|
when 'finish' then publisher.finish(version)
|
|
else abort 'usage: published-channels.rb prepare|finish VERSION'
|
|
end
|
|
end
|