diff --git a/.agents/skills/cpr-release/SKILL.md b/.agents/skills/cpr-release/SKILL.md index 39558528d..c27ab0658 100644 --- a/.agents/skills/cpr-release/SKILL.md +++ b/.agents/skills/cpr-release/SKILL.md @@ -20,7 +20,7 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行 - Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替 - `release/publish --check ` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权 - 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突 -- 备版只更新 `release/version.yaml` 的 `version`;工作流确认 Release 公开、附件齐全后才推进 `main` 上的 `published`,验收时分别核对 +- 备版只更新源码的构建版本;通道快照是版本 Release 的 `version.yaml` 附件,验收时核对固定预发行页 `update-index` 指向的公开快照 - 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对 - `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md) - 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功 diff --git a/.agents/skills/cpr-release/references/experimental.md b/.agents/skills/cpr-release/references/experimental.md index 3a6362b7a..e2bbf111e 100644 --- a/.agents/skills/cpr-release/references/experimental.md +++ b/.agents/skills/cpr-release/references/experimental.md @@ -27,7 +27,7 @@ 应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。 发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。 - GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。 - 发布任务应将该基线的最高公开实验版写入 main 的 `published[大版本].exp[基线]`,不推进普通通道。 + 发布任务应将该基线的最高公开实验版写入版本 Release 通道快照的 `published[大版本].exp[基线]`,不推进普通通道。 - 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。 对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。 - 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时, diff --git a/.agents/skills/cpr-release/references/operations.md b/.agents/skills/cpr-release/references/operations.md index 24b7ade58..92bd6f1f1 100644 --- a/.agents/skills/cpr-release/references/operations.md +++ b/.agents/skills/cpr-release/references/operations.md @@ -46,9 +46,7 @@ release/publish "$release_version" - 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。 - 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。 工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。 -- 回读 `main/release/version.yaml` 的 `published`:正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。晚完成的发行不得回退较高候选;保留备版字段和其他大版本 -- 若 Release 和镜像均已发布,只有索引写回失败,使用包含发布脚本的工作区,在既有远程发布授权内单独执行 - `GITHUB_REPOSITORY=owner/repo ruby .github/scripts/published-channels.rb "$release_version"`(使用已认证的 `gh`)。 - 脚本按 tag 复核公开状态、平台包与附件,以文件 SHA 防止覆盖并发修改。持续冲突或无权写入 main 时报告失败并核对分支规则;不重新打包或移动 tag。回读 main 确认后再报告索引已推进;raw 缓存传播期间,客户端可能短暂看到上一候选。索引最多 8 KiB,接近上限时核实并移除已结束维护的实验基线,不删仍受支持的通道 +- 回读固定预发行页 `update-index`(不是 GitHub Latest),按正文 JSON 的 `current` 下载对应 `v` 的 `version.yaml`。核对快照版本及 `published`:正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。晚完成的发行不得回退较高候选或丢失其他发行线 +- 只有指针写入失败时,在既有授权内执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务与发布共用锁,只核对已公开版本的附件和待发布快照摘要,不重新打包、改版本 tag 或写 main。完成后回读指针与附件;待发布记录不能当作成功。索引附件最多 128 KiB - 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功; Release 和镜像已生成也不表示用户的运行实例已经升级。 diff --git a/.agents/skills/cpr-release/references/preparation.md b/.agents/skills/cpr-release/references/preparation.md index e77626584..7ba6782e1 100644 --- a/.agents/skills/cpr-release/references/preparation.md +++ b/.agents/skills/cpr-release/references/preparation.md @@ -5,7 +5,7 @@ ## 基线与版本 1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对 -2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分备版字段 `version` 和已发布通道表 `published`,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交 +2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分源码构建版本和 `update-index` 指向的发行附件中的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交 3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突 4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本) diff --git a/.github/scripts/published-channels.rb b/.github/scripts/published-channels.rb index f07f6f8cd..45aaf7c74 100644 --- a/.github/scripts/published-channels.rb +++ b/.github/scripts/published-channels.rb @@ -1,11 +1,12 @@ require 'base64' +require 'digest' require 'uri' require_relative 'release-metadata' -# 所有发行线只推进 main 中已公开可下载的候选,保留备版字段 +# 通道快照随版本封存,固定 Release 只记录已公开快照和可恢复的待发布摘要 module PublishedChannels - PATH = 'release/version.yaml' - LIMIT = 8192 + POINTER = 'update-index' + LIMIT = 128 * 1024 def self.advance(document, version) version = ReleaseMetadata.version(version) @@ -34,49 +35,136 @@ module PublishedChannels changed end - def self.api(endpoint, payload = nil) - args = ['gh', 'api', endpoint] - args.concat(%w[--method PUT --input -]) if payload - out, error, status = Open3.capture3(*args, stdin_data: payload ? JSON.generate(payload) : '') - return :conflict if payload && !status.success? && error.include?('(HTTP 409)') - raise "Cannot access #{endpoint}: #{error.strip}" unless status.success? - JSON.parse(out) - end - - def self.verify_assets(base, version, request) - tag = "v#{version}" - release = request.call("#{base}/releases/tags/#{tag}") - raise "Release is not public or tag identity differs: #{tag}" unless release.fetch('tag_name') == tag && - !release.fetch('draft') && release.fetch('prerelease') == ReleaseMetadata.prerelease?(version) - platforms = request.call("#{base}/contents/release/platforms.yaml?ref=#{URI.encode_www_form_component(tag)}") - expected = YAML.safe_load(Base64.decode64(platforms.fetch('content'))).fetch('platforms').map do |p| - ReleaseMetadata.archive_name(version, p) + 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 - assets = release.fetch('assets').select { |a| a.fetch('state') == 'uploaded' && a.fetch('size').positive? }.map { |a| a.fetch('name') } - missing = (expected + %w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json]) - assets - raise "Incomplete release #{tag}: #{missing.join(', ')}" if expected.empty? || !missing.empty? - end - def self.publish(repository, version, &request) - request ||= method(:api) - version = ReleaseMetadata.version(version) - base = "repos/#{repository}" - verify_assets(base, version, request) - endpoint = "#{base}/contents/#{PATH}" - 3.times do - file = request.call("#{endpoint}?ref=main") - document = YAML.safe_load(Base64.decode64(file.fetch('content'))) - return unless advance(document, version) - content = YAML.dump(document) + 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 - written = request.call(endpoint, { branch: 'main', sha: file.fetch('sha'), content: Base64.strict_encode64(content), - message: "chore(release): publish channel index for v#{version}" }) - return unless written == :conflict + 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 - raise 'main changed during channel publication; rerun the index step' end end if $PROGRAM_NAME == __FILE__ - PublishedChannels.publish(ENV.fetch('GITHUB_REPOSITORY'), ARGV.fetch(0)) + 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 diff --git a/.github/scripts/tests/published-channels-test.rb b/.github/scripts/tests/published-channels-test.rb new file mode 100644 index 000000000..de266a82d --- /dev/null +++ b/.github/scripts/tests/published-channels-test.rb @@ -0,0 +1,115 @@ +require 'tmpdir' +require_relative '../published-channels' + +# 测试只替换 GitHub 读写边界,执行真实的发布状态转换 +class PublishedChannelsTest + def assert_equal(expected, actual) + raise "expected #{expected.inspect}, got #{actual.inspect}" unless expected == actual + end + + def assert_rejected + yield + rescue RuntimeError + return + else + raise 'expected publication to be rejected' + end + + def initialize + @pointer = nil + @releases = {} + @files = {} + @calls = [] + @platforms = [{ 'os' => 'linux', 'arch' => 'amd64', 'archive' => 'tar.gz' }] + end + + def api(endpoint, method: 'GET', data: nil, optional: false) + path = endpoint.delete_prefix('repos/test/repo/') + @calls << [method, path] + case [method, path] + when ['GET', 'releases/tags/update-index'] then @pointer + when ['POST', 'releases'] + raise 'duplicate pointer' if @pointer + @pointer = data.transform_keys(&:to_s).merge('id' => 12, 'draft' => false) + when ['PATCH', 'releases/12'] + @pointer.merge!(data.transform_keys(&:to_s)) + when ['GET', 'releases/latest'] then @releases['3.41.2'] + else + return @releases[path.delete_prefix('releases/tags/v')] if method == 'GET' && path.start_with?('releases/tags/v') + return { 'content' => Base64.encode64(YAML.dump({ 'platforms' => @platforms })) } if path.start_with?('contents/release/platforms.yaml?ref=v') + raise "unexpected #{method} #{path}" + end + end + + def publish_assets(version, content = nil) + names = %w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json] + names << ReleaseMetadata.archive_name(version, @platforms.first) + @files[version] = content if content + assets = names.map { |name| { 'name' => name, 'size' => 100, 'state' => 'uploaded' } } + assets << { 'name' => 'version.yaml', 'size' => content.bytesize, 'digest' => "sha256:#{Digest::SHA256.hexdigest(content)}", 'state' => 'uploaded' } if content + @releases[version] = { 'tag_name' => "v#{version}", 'draft' => false, 'prerelease' => ReleaseMetadata.prerelease?(version), 'assets' => assets } + end + + def publisher + github = self + files = @files + Class.new(PublishedChannels::Publisher) do + define_method(:api) { |endpoint, **args| github.api(endpoint, **args) } + define_method(:download) { |version| files.fetch(version) } + end.new('test/repo') + end + + def prepare(version) + Dir.mktmpdir do |dir| + File.write(File.join(dir, 'checksums.txt'), '') + publisher.prepare(version, dir) + content = File.read(File.join(dir, 'version.yaml')) + assert_equal "#{Digest::SHA256.hexdigest(content)} version.yaml\n", File.read(File.join(dir, 'checksums.txt')) + content + end + end + + def current = JSON.parse(@pointer.fetch('body')).fetch('current') + + def test_interrupted_publication_recovers_without_losing_another_channel + publish_assets('3.41.2') + stable = prepare('3.42.0') + assert_equal nil, current + assert_rejected { publisher.finish('3.42.0') } + publish_assets('3.42.0', stable) + # 模拟公开后更新指针中断,下一次发布在同一把锁内补写,再合并自己的通道 + beta = prepare('3.43.0-beta.1') + assert_equal '3.42.0', current + assert_equal({ 'stable' => '3.42.0', 'rc' => '3.42.0', 'beta' => '3.43.0-beta.1', 'alpha' => '3.43.0-beta.1' }, + YAML.safe_load(beta).dig('published', '3')) + publish_assets('3.43.0-beta.1', beta) + publisher.finish('3.43.0-beta.1') + publisher.finish('3.43.0-beta.1') + assert_rejected { publisher.finish('3.42.0') } + assert_equal '3.43.0-beta.1', current + experiment = YAML.safe_load(prepare('3.44.0-exp.1')).dig('published', '3') + assert_equal({ '3.44.0' => '3.44.0-exp.1' }, experiment.fetch('exp')) + assert_equal '3.43.0-beta.1', experiment.fetch('beta') + assert_equal false, @calls.include?(['GET', 'releases']) + end + + def test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot + publish_assets('3.42.0', prepare('3.42.0')) + publisher.finish('3.42.0') + beta = prepare('3.43.0-beta.1') + publish_assets('3.43.0-beta.1', beta) + release = @releases.fetch('3.43.0-beta.1') + release['draft'] = true + assert_rejected { publisher.finish('3.43.0-beta.1') } + release['draft'] = false + missing = release.fetch('assets').shift + assert_rejected { publisher.finish('3.43.0-beta.1') } + release.fetch('assets') << missing + publish_assets('3.43.0-beta.1', beta.sub('3.42.0', '3.10.0')) + assert_rejected { publisher.finish('3.43.0-beta.1') } + assert_equal '3.42.0', current + end +end +PublishedChannelsTest.new.test_interrupted_publication_recovers_without_losing_another_channel +PublishedChannelsTest.new.test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot +puts 'Published channel recovery and integrity checks passed' diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index cc5ba7035..846d8feb9 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -258,4 +258,5 @@ jobs: for script in .github/**/*.sh; do bash -n "$script"; done for script in .github/scripts/*.rb; do ruby -c "$script"; done ruby .github/scripts/toolchains.rb + ruby .github/scripts/tests/published-channels-test.rb ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d17f0dcd2..92263b9cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,10 @@ on: description: 'Tag to release, for example v0.1.0' required: true type: string + index_only: + description: 'Recover the index for an already public release without rebuilding' + type: boolean + default: false permissions: contents: read @@ -21,6 +25,7 @@ env: jobs: release-info: + if: ${{ !inputs.index_only }} runs-on: ubuntu-26.04 timeout-minutes: 10 permissions: @@ -239,8 +244,6 @@ jobs: timeout-minutes: 20 permissions: contents: read - id-token: write - attestations: write steps: - name: Checkout @@ -274,11 +277,6 @@ jobs: PLATFORM_MATRIX: ${{ needs.release-info.outputs.platform_matrix }} run: ruby .github/scripts/package-release.rb archives - - name: Generate release asset provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: dist/release/* - - name: Upload release assets uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -540,6 +538,17 @@ jobs: File.write(output, "#{notes}\n\n#{installation}\n") RUBY + - name: Prepare versioned channel snapshot under the publication lock + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.release-info.outputs.version }} + run: ruby .github/scripts/published-channels.rb prepare "$VERSION" + + - name: Generate release asset provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/release/* + - name: Upload GitHub Release assets id: release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 @@ -564,8 +573,24 @@ jobs: RELEASE_ID: ${{ steps.release.outputs.id }} run: ruby .github/scripts/publish-release.rb - - name: Advance published channels on main + - name: Advance update-index to the verified snapshot env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release-info.outputs.version }} - run: ruby .github/scripts/published-channels.rb "$VERSION" + run: ruby .github/scripts/published-channels.rb finish "$VERSION" + + recover-index: + if: inputs.index_only + runs-on: ubuntu-26.04 + concurrency: + group: release-publication + queue: max + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Verify public assets and finish pending publication + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + run: ruby .github/scripts/published-channels.rb finish "$TAG" diff --git a/backend/crates/gateway-host/src/system_update/discovery.rs b/backend/crates/gateway-host/src/system_update/discovery.rs index e60f455ad..9bc673c58 100644 --- a/backend/crates/gateway-host/src/system_update/discovery.rs +++ b/backend/crates/gateway-host/src/system_update/discovery.rs @@ -104,7 +104,27 @@ impl ReleaseCache { }; let summary = index.and_then(|index| candidate(config, channel, &index)); match summary { - Ok((mut detail, _)) => { + Ok((mut detail, target)) => { + if detail.has_update { + let slot = self + .releases + .lock() + .await + .iter() + .find(|(v, _)| v == &target) + .map(|(_, s)| Arc::clone(s)); + let checked = match slot { + Some(slot) => slot.cached(NORMAL).await, + None => None, + }; + match checked { + Some(Ok(Some(_))) => {} + Some(Err(error)) => { + return base_update_detail(config, channel, None, Some(&error)); + } + _ => return base_update_detail(config, channel, None, None), + } + } detail.cached = true; detail } @@ -134,9 +154,11 @@ impl ReleaseCache { .await?; let (mut detail, target) = candidate(config, channel, &index)?; detail.cached = cached; - if check != Check::Summary { + if check != Check::Summary || detail.has_update { let (release, reused) = self.exact(config, &target, age, diagnostics).await?; - if let Some(release) = release { + if let Some(release) = release + && check != Check::Summary + { detail = detail_from_release(config, &release, channel); } detail.cached = cached && reused; diff --git a/backend/crates/gateway-host/src/system_update/download.rs b/backend/crates/gateway-host/src/system_update/download.rs index d8e5fefe6..b6eb4e377 100644 --- a/backend/crates/gateway-host/src/system_update/download.rs +++ b/backend/crates/gateway-host/src/system_update/download.rs @@ -9,12 +9,11 @@ use futures::StreamExt as _; use sha2::Digest as _; use super::events::UpdateEvents; -use super::release::{download_client, validate_download_url}; +use super::release::{ + MAX_CHECKSUM_SIZE, MAX_DOWNLOAD_SIZE, download_client, validate_download_url, +}; use super::{OperationError, invalid, upstream}; -pub(crate) const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; -const MAX_CHECKSUM_SIZE: u64 = 1024 * 1024; - pub(crate) async fn download_file( url: &str, destination: &Path, diff --git a/backend/crates/gateway-host/src/system_update/metadata.rs b/backend/crates/gateway-host/src/system_update/metadata.rs index 054f4b823..e9258d440 100644 --- a/backend/crates/gateway-host/src/system_update/metadata.rs +++ b/backend/crates/gateway-host/src/system_update/metadata.rs @@ -7,6 +7,7 @@ use chrono::{DateTime, Utc}; use gateway_admin::model::system::SystemUpdateChannel as Channel; use reqwest::{Client, StatusCode, Url}; use semver::Version; +use serde::Deserialize; use tokio::sync::{Mutex, OnceCell}; use tokio::time::Instant; use yaml_rust2::{ @@ -14,13 +15,14 @@ use yaml_rust2::{ parser::{Event, Parser}, }; -use super::release::{GitHubRelease, channel_accepts, channel_for_version}; +use super::release::{GitHubRelease, channel_accepts, channel_for_version, download_client}; use super::{OperationError, SystemUpdateConfig, invalid, upstream}; #[derive(Default)] pub(super) struct Metadata { client: OnceCell, - raw_until: Mutex>, + snapshot: Mutex)>>, + asset_until: Mutex>, api_until: Mutex>, } @@ -41,18 +43,42 @@ impl Metadata { &self, config: &SystemUpdateConfig, ) -> Result, OperationError> { - let repo = repository(config)?; + #[derive(Deserialize)] + struct Pointer { + current: Option, + } + let pointer = self + .by_tag(config, "update-index") + .await? + .filter(|r| r.tag_name == "update-index" && !r.draft && r.prerelease) + .ok_or_else(|| upstream("更新仓库缺少公开的 update-index 指针"))?; + let pointer: Pointer = serde_json::from_str(pointer.body.as_deref().unwrap_or_default()) + .map_err(|_| upstream("发行索引指针格式无效"))?; + let version = pointer + .current + .ok_or_else(|| upstream("尚未发布通道索引"))?; + let version = Version::parse(&version).map_err(|_| upstream("发行索引指针版本无效"))?; + let mut snapshot = self.snapshot.lock().await; + if let Some((previous, yaml)) = snapshot.as_ref() + && previous == &version + { + return Ok(Arc::clone(yaml)); + } + // 正文只接受版本号,下载地址始终由已配置的仓库和固定附件名构造 let mut url = Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?; if url.host_str() == Some("api.github.com") { - url = Url::parse("https://raw.githubusercontent.com").expect("固定的 GitHub raw 源"); + url = Url::parse("https://github.com").expect("固定的 GitHub 发行源"); } - // 配置校验只额外接受回环源,测试时索引和 Release 均留在本机 - url.set_path(&format!("/{repo}/refs/heads/main/release/version.yaml")); + url.set_path(&format!( + "/{}/releases/download/v{version}/version.yaml", + repository(config)? + )); + let client = download_client(&config.github_api_base, Duration::from_secs(10))?; let bytes = self - .read(url.as_str(), &self.raw_until, 8192, false) + .read(client.get(url), &self.asset_until, 128 * 1024) .await? - .ok_or_else(|| upstream("更新仓库缺少发布索引"))?; + .ok_or_else(|| upstream("发行附件缺少 version.yaml"))?; let source = std::str::from_utf8(&bytes).map_err(|_| upstream("发布索引格式无效"))?; let bad = || upstream("发布索引格式无效"); // 远程 YAML 不使用别名,限制嵌套后再构造节点,避免小响应放大内存 @@ -76,7 +102,12 @@ impl Metadata { if docs.len() != 1 || docs[0]["published"].as_hash().is_none() { return Err(upstream("发布索引缺少有效的 published 通道表")); } - Ok(Arc::new(docs.remove(0))) + let yaml = Arc::new(docs.remove(0)); + if yaml["version"].as_str() != Some(version.to_string().as_str()) { + return Err(upstream("发行索引附件与指针版本不符")); + } + *snapshot = Some((version, Arc::clone(&yaml))); + Ok(yaml) } pub(super) async fn release( @@ -84,47 +115,37 @@ impl Metadata { config: &SystemUpdateConfig, version: &Version, ) -> Result>, OperationError> { - let mut url = - Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?; + let stable = version.pre.is_empty(); + let version = version.to_string(); let tag = format!("v{version}"); - url.path_segments_mut() - .map_err(|()| invalid("发行查询地址无效"))? - .pop_if_empty() - .extend(repository(config)?.split('/')) - .extend(["releases", "tags", &tag]); - let Some(bytes) = self - .read(url.as_str(), &self.api_until, 4 * 1024 * 1024, true) - .await? - else { + let Some(release) = self.by_tag(config, &tag).await? else { // 当前运行版本可以没有公开说明;索引承诺的升级目标缺失属于短期查询失败 - if config.version.trim_start_matches('v') == version.to_string() { + if config.version.trim_start_matches('v') == version { return Ok(None); } return Err(upstream("发布索引指向的 Release 不存在")); }; - let release: GitHubRelease = serde_json::from_slice(&bytes) - .map_err(|error| upstream("GitHub 发行信息格式无效").with_source(error))?; - if release.tag_name != tag || release.draft || release.prerelease == version.pre.is_empty() - { + if release.tag_name != tag || release.draft || release.prerelease == stable { return Err(upstream("GitHub 发行身份与发布索引不符")); } + if config.version.trim_start_matches('v') != version { + release.install_assets(&version, &config.github_api_base)?; + } Ok(Some(Arc::new(release))) } - async fn read( + async fn by_tag( &self, - url: &str, - cooldown: &Mutex>, - limit: usize, - api: bool, - ) -> Result>, OperationError> { - if let Some(active) = cooldown - .lock() - .await - .filter(|active| active.until > Instant::now()) - { - return Err(active.warning()); - } + config: &SystemUpdateConfig, + tag: &str, + ) -> Result, OperationError> { + let mut url = + Url::parse(&config.github_api_base).map_err(|_| invalid("发行查询地址无效"))?; + url.path_segments_mut() + .map_err(|()| invalid("发行查询地址无效"))? + .pop_if_empty() + .extend(repository(config)?.split('/')) + .extend(["releases", "tags", tag]); let client = self .client .get_or_try_init(|| async { @@ -138,15 +159,40 @@ impl Metadata { }) .await .map_err(|e| super::error::http("无法初始化发行查询连接", e))?; - let request = client.get(url); - let mut response = if api { - request.header("Accept", "application/vnd.github+json") - } else { - request + let bytes = self + .read( + client + .get(url) + .header("Accept", "application/vnd.github+json"), + &self.api_until, + 4 * 1024 * 1024, + ) + .await?; + bytes + .map(|bytes| { + serde_json::from_slice(&bytes) + .map_err(|error| upstream("GitHub 发行信息格式无效").with_source(error)) + }) + .transpose() + } + + async fn read( + &self, + request: reqwest::RequestBuilder, + cooldown: &Mutex>, + limit: usize, + ) -> Result>, OperationError> { + if let Some(active) = cooldown + .lock() + .await + .filter(|active| active.until > Instant::now()) + { + return Err(active.warning()); } - .send() - .await - .map_err(|e| super::error::http("无法查询 GitHub 发行版本", e))?; + let mut response = request + .send() + .await + .map_err(|e| super::error::http("无法查询 GitHub 发行版本", e))?; let status = response.status(); if status == StatusCode::NOT_FOUND { return Ok(None); diff --git a/backend/crates/gateway-host/src/system_update/operations.rs b/backend/crates/gateway-host/src/system_update/operations.rs index 7d8ec7af1..b80c05f06 100644 --- a/backend/crates/gateway-host/src/system_update/operations.rs +++ b/backend/crates/gateway-host/src/system_update/operations.rs @@ -17,22 +17,20 @@ use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard, broadcast}; use super::archive::extract_release; use super::discovery::ReleaseCache; -use super::download::{ - MAX_DOWNLOAD_SIZE, download_file, fetch_expected_checksum, format_bytes, verify_checksum, -}; +use super::download::{download_file, fetch_expected_checksum, format_bytes, verify_checksum}; use super::events::UpdateEvents; use super::installation::{OFFICIAL_PLUGIN_MANIFEST, ReleaseFiles}; use super::process::spawn_replacement; use super::release::{ - GitHubRelease, base_update_detail, confirmed_target, select_archive, update_policy, - validate_download_url, validate_update_target, version_channel, + GitHubRelease, base_update_detail, confirmed_target, update_policy, validate_update_target, + version_channel, }; use super::state::{ OperationFileLock, UpdateOperation, UpdateTempDir, finish, operation_id, read_status, reconcile_installation, recover_interrupted, set_running, }; use super::swap::{replace_release_files, rollback_official_plugins_dir, rollback_release}; -use super::{OperationError, SystemUpdateConfig, conflict, internal, invalid, upstream}; +use super::{OperationError, SystemUpdateConfig, conflict, internal, invalid}; const MAX_OFFICIAL_PLUGIN_MANIFEST_SIZE: u64 = 256 * 1024; @@ -211,7 +209,7 @@ impl ProcessSystemOperations { Some("asset"), "正在选择匹配当前平台的更新包", ); - let archive = select_archive(release, version)?; + let (archive, checksum) = release.install_assets(version, &self.config.github_api_base)?; self.events.info( Some(operation_id), Some("asset"), @@ -223,15 +221,6 @@ impl ProcessSystemOperations { ); self.events .info(Some(operation_id), Some("verify"), "正在校验更新资源"); - if archive.size == 0 || archive.size > MAX_DOWNLOAD_SIZE { - return Err(invalid("更新包大小无效或超出限制")); - } - let checksum = release - .assets - .iter() - .find(|asset| asset.name == "checksums.txt") - .ok_or_else(|| upstream("发行版本缺少 checksums.txt 校验文件,无法安全更新"))?; - validate_download_url(&archive.browser_download_url, &self.config.github_api_base)?; self.events.info( Some(operation_id), Some("prepare"), diff --git a/backend/crates/gateway-host/src/system_update/release.rs b/backend/crates/gateway-host/src/system_update/release.rs index 10a7cccf0..1b733a8ec 100644 --- a/backend/crates/gateway-host/src/system_update/release.rs +++ b/backend/crates/gateway-host/src/system_update/release.rs @@ -10,6 +10,9 @@ use serde::Deserialize; use super::{OperationError, SystemUpdateConfig, conflict, invalid}; +pub(super) const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; +pub(super) const MAX_CHECKSUM_SIZE: u64 = 1024 * 1024; + const APP_BINARY_NAME: &str = "codex-proxy-rs"; #[derive(Debug, Clone, Deserialize)] @@ -31,6 +34,44 @@ pub(crate) struct GitHubAsset { pub(crate) size: u64, } +impl GitHubRelease { + pub(super) fn install_assets( + &self, + version: &str, + api_base: &str, + ) -> Result<(&GitHubAsset, &GitHubAsset), OperationError> { + let os = match env::consts::OS { + "macos" => "darwin", + os => os, + }; + let arch = match env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + arch => arch, + }; + let name = format!("{APP_BINARY_NAME}_{version}_{os}_{arch}.tar.gz"); + let archive = self + .assets + .iter() + .find(|a| a.name == name) + .ok_or_else(|| conflict(format!("当前发行版本没有适用于 {os}/{arch} 的更新包")))?; + if !(1..=MAX_DOWNLOAD_SIZE).contains(&archive.size) { + return Err(invalid("更新包大小无效或超出限制")); + } + let checksum = self + .assets + .iter() + .find(|a| a.name == "checksums.txt") + .ok_or_else(|| super::upstream("发行版本缺少 checksums.txt 校验文件,无法安全更新"))?; + if !(1..=MAX_CHECKSUM_SIZE).contains(&checksum.size) { + return Err(invalid("校验文件大小无效或超出限制")); + } + validate_download_url(&archive.browser_download_url, api_base)?; + validate_download_url(&checksum.browser_download_url, api_base)?; + Ok((archive, checksum)) + } +} + pub(crate) fn detail_from_release( config: &SystemUpdateConfig, release: &GitHubRelease, @@ -70,36 +111,6 @@ pub(crate) fn confirmed_target(target: Option) -> Result( - release: &'a GitHubRelease, - version: &str, -) -> Result<&'a GitHubAsset, OperationError> { - let os = platform_os_aliases(); - let arch = platform_arch_aliases(); - let normalized = normalize_version(version); - release - .assets - .iter() - .find(|asset| { - asset.name.contains(APP_BINARY_NAME) - && asset.name.contains(&normalized) - && asset_matches_platform(&asset.name, os, arch) - && !asset.name.ends_with(".txt") - }) - .or_else(|| { - release.assets.iter().find(|asset| { - asset_matches_platform(&asset.name, os, arch) && !asset.name.ends_with(".txt") - }) - }) - .ok_or_else(|| { - conflict(format!( - "当前发行版本没有适用于 {}/{} 的更新包", - env::consts::OS, - env::consts::ARCH - )) - }) -} - /// 校验 GitHub Release 资产 URL 及其重定向目标是否位于受信任边界内 pub fn validate_download_url(raw: &str, api_base: &str) -> Result<(), OperationError> { let url = reqwest::Url::parse(raw) @@ -128,7 +139,7 @@ pub(crate) fn download_client( reqwest::Client::builder() .timeout(timeout) .redirect(reqwest::redirect::Policy::custom(move |attempt| { - if download_url_allowed(attempt.url(), &api_base) { + if attempt.previous().len() < 5 && download_url_allowed(attempt.url(), &api_base) { attempt.follow() } else { attempt.error(std::io::Error::other( @@ -264,26 +275,6 @@ fn repository_character(character: char) -> bool { character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') } -fn platform_os_aliases() -> &'static [&'static str] { - match env::consts::OS { - "macos" => &["macos", "darwin"], - "linux" => &["linux"], - _ => &[env::consts::OS], - } -} - -fn platform_arch_aliases() -> &'static [&'static str] { - match env::consts::ARCH { - "x86_64" => &["x86_64", "amd64"], - "aarch64" => &["aarch64", "arm64"], - _ => &[env::consts::ARCH], - } -} - -fn asset_matches_platform(name: &str, os: &[&str], arch: &[&str]) -> bool { - os.iter().any(|alias| name.contains(alias)) && arch.iter().any(|alias| name.contains(alias)) -} - fn github_download_host_allowed(host: &str) -> bool { host == "github.com" || host.ends_with(".github.com") diff --git a/backend/crates/gateway-host/tests/system_update/channels.rs b/backend/crates/gateway-host/tests/system_update/channels.rs index 1c23ca561..1b46ed707 100644 --- a/backend/crates/gateway-host/tests/system_update/channels.rs +++ b/backend/crates/gateway-host/tests/system_update/channels.rs @@ -13,7 +13,7 @@ async fn temporary_channel_should_filter_future_releases_without_changing_runnin .and(path(format!( "/repos/owner/repository/releases/tags/v{version}" ))) - .respond_with(release_response(version, vec![])) + .respond_with(release_response(version, available_assets(version))) .expect(1) .mount(&server) .await; @@ -49,7 +49,7 @@ async fn temporary_channel_should_filter_future_releases_without_changing_runnin assert_eq!(version.latest_version, "1.1.0"); assert!(!fixture.state().exists(), "检查通道不能写入实例偏好"); let requests = server.received_requests().await.expect("requests"); - assert_eq!(requests.len(), 5); + assert_eq!(requests.len(), 6); } #[tokio::test] diff --git a/backend/crates/gateway-host/tests/system_update/discovery.rs b/backend/crates/gateway-host/tests/system_update/discovery.rs index 423a88797..295f4c337 100644 --- a/backend/crates/gateway-host/tests/system_update/discovery.rs +++ b/backend/crates/gateway-host/tests/system_update/discovery.rs @@ -39,10 +39,24 @@ async fn discovery_request_budget_is_constant_under_load() { .expect(0) .mount(&server) .await; + let mut snapshot = fixture_index(&target); + if history >= 1000 { + // 实际快照同时保留一千条实验基线,列表中的历史发行仍不应被读取 + snapshot["published"]["1"]["exp"] = (0..1000) + .map(|i| { + ( + format!("1.2.{i}"), + serde_json::Value::from(format!("1.2.{i}-exp.1")), + ) + }) + .collect::>() + .into(); + } + mount_pointer(&server).await; Mock::given(path(INDEX_PATH)) .respond_with( ResponseTemplate::new(200) - .set_body_json(fixture_index(&target)) + .set_body_json(snapshot) .set_delay(Duration::from_millis(80)), ) .expect(1) @@ -51,7 +65,10 @@ async fn discovery_request_budget_is_constant_under_load() { Mock::given(path(format!( "/repos/owner/repository/releases/tags/v{target}" ))) - .respond_with(release_response(&target, vec![]).set_delay(Duration::from_millis(30))) + .respond_with( + release_response(&target, available_assets(&target)) + .set_delay(Duration::from_millis(30)), + ) .expect(1) .mount(&server) .await; @@ -64,7 +81,7 @@ async fn discovery_request_budget_is_constant_under_load() { .unwrap(); assert_eq!(summary.latest_version, target); assert!(summary.notes.is_none()); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!(server.received_requests().await.unwrap().len(), 3); let mut callers = tokio::task::JoinSet::new(); for i in 0..512 { @@ -86,9 +103,9 @@ async fn discovery_request_budget_is_constant_under_load() { for _ in 0..64 { assert!(service.version().await.unwrap().has_update); } - assert_eq!(server.received_requests().await.unwrap().len(), 2); + assert_eq!(server.received_requests().await.unwrap().len(), 3); eprintln!( - "history={history} concurrent_detail=512 local_probes=65 raw=1 rest=1 lists=0 elapsed={:?}", + "history={history} concurrent_detail=512 local_probes=65 pointer=1 snapshot=1 release=1 lists=0 elapsed={:?}", started.elapsed() ); } @@ -100,6 +117,7 @@ async fn cold_summary_stampede_and_slow_source_do_not_block_runtime_probes() { let fixture = Fixture::new(); let service = Arc::new(fixture.service(&server)); let (started, mut observed) = tokio::sync::mpsc::unbounded_channel(); + mount_pointer(&server).await; Mock::given(path(INDEX_PATH)) .respond_with(move |_: &wiremock::Request| { started.send(()).unwrap(); @@ -110,6 +128,16 @@ async fn cold_summary_stampede_and_slow_source_do_not_block_runtime_probes() { .expect(1) .mount(&server) .await; + Mock::given(path(format!( + "/repos/owner/repository/releases/tags/v{TARGET_VERSION}" + ))) + .respond_with(release_response( + TARGET_VERSION, + available_assets(TARGET_VERSION), + )) + .expect(1) + .mount(&server) + .await; let mut callers = tokio::task::JoinSet::new(); for _ in 0..256 { let service = service.clone(); @@ -133,11 +161,11 @@ async fn cold_summary_stampede_and_slow_source_do_not_block_runtime_probes() { while let Some(summary) = callers.join_next().await { assert!(summary.unwrap().has_update); } - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!(server.received_requests().await.unwrap().len(), 3); } #[tokio::test] -async fn api_cooldown_is_shared_across_channels_and_does_not_block_raw_summary() { +async fn api_cooldown_is_shared_and_summary_does_not_advertise_an_unverified_release() { for status in [403, 429] { let server = MockServer::start().await; let fixture = Fixture::new(); @@ -181,13 +209,13 @@ async fn api_cooldown_is_shared_across_channels_and_does_not_block_raw_summary() assert!(!second.has_update); } assert!( - service + !service .update_detail(SystemUpdateCheck::Summary, None) .await .unwrap() .has_update ); - assert_eq!(server.received_requests().await.unwrap().len(), 2); + assert_eq!(server.received_requests().await.unwrap().len(), 3); } } @@ -196,14 +224,15 @@ async fn invalid_or_missing_published_data_does_not_fallback_to_build_version_or for body in [ "version: '1.9.9'".to_owned(), "version: '9.0.0'\npublished: {'2': {stable: '2.0.0'}}".to_owned(), - "published: {'1': {stable: '2.0.0'}}".to_owned(), - "published: {'1': {stable: '1.2.0-rc.1'}}".to_owned(), + format!("version: '{SNAPSHOT}'\npublished: {{'1': {{stable: '2.0.0'}}}}"), + format!("version: '{SNAPSHOT}'\npublished: {{'1': {{stable: '1.2.0-rc.1'}}}}"), "bad: &loop [1]\npublished: *loop".to_owned(), - "x".repeat(8193), + "x".repeat(128 * 1024 + 1), ] { let server = MockServer::start().await; let fixture = Fixture::new(); let service = fixture.service(&server); + mount_pointer(&server).await; Mock::given(path(INDEX_PATH)) .respond_with(ResponseTemplate::new(200).set_body_string(body)) .expect(1) @@ -217,7 +246,7 @@ async fn invalid_or_missing_published_data_does_not_fallback_to_build_version_or assert!(detail.warning.is_some()); assert!(!detail.has_update); } - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!(server.received_requests().await.unwrap().len(), 2); } } @@ -254,20 +283,35 @@ async fn install_rechecks_even_a_warm_candidate_and_never_downloads_a_changed_ta let fixture = Fixture::new(); let service = fixture.service(&server); let requests = AtomicUsize::new(0); - Mock::given(path(INDEX_PATH)) + Mock::given(path(POINTER_PATH)) .respond_with(move |_: &wiremock::Request| { - let v = if requests.fetch_add(1, Ordering::SeqCst) == 0 { - "1.1.0" + pointer_response(if requests.fetch_add(1, Ordering::SeqCst) == 0 { + SNAPSHOT } else { - "1.2.0" - }; - ResponseTemplate::new(200).set_body_json(fixture_index(v)) + "4.0.0" + }) }) .expect(2) .mount(&server) .await; + for (path_name, latest, target) in [ + (INDEX_PATH, SNAPSHOT, "1.1.0"), + ( + "/owner/repository/releases/download/v4.0.0/version.yaml", + "4.0.0", + "1.2.0", + ), + ] { + let mut index = fixture_index(target); + index["version"] = latest.into(); + Mock::given(path(path_name)) + .respond_with(ResponseTemplate::new(200).set_body_json(index)) + .expect(1) + .mount(&server) + .await; + } Mock::given(path("/repos/owner/repository/releases/tags/v1.1.0")) - .respond_with(release_response("1.1.0", vec![])) + .respond_with(release_response("1.1.0", available_assets("1.1.0"))) .expect(1) .mount(&server) .await; @@ -279,7 +323,7 @@ async fn install_rechecks_even_a_warm_candidate_and_never_downloads_a_changed_ta let status = complete_update(&service, &detail.latest_version).await; assert_eq!(status.operation.status, SystemOperationStatus::Failed); assert!(status.operation.error.unwrap().contains("重新检查并确认")); - assert_eq!(server.received_requests().await.unwrap().len(), 3); + assert_eq!(server.received_requests().await.unwrap().len(), 5); } #[tokio::test] @@ -287,7 +331,13 @@ async fn channels_pointing_to_one_release_share_its_detail() { let server = MockServer::start().await; let fixture = Fixture::new(); let service = fixture.service(&server); - mount_release_json(&server, TARGET_VERSION, vec![], None).await; + mount_release_json( + &server, + TARGET_VERSION, + available_assets(TARGET_VERSION), + None, + ) + .await; for channel in [ SystemUpdateChannel::Stable, SystemUpdateChannel::Rc, @@ -302,7 +352,7 @@ async fn channels_pointing_to_one_release_share_its_detail() { .has_update ); } - assert_eq!(server.received_requests().await.unwrap().len(), 2); + assert_eq!(server.received_requests().await.unwrap().len(), 3); } #[tokio::test] @@ -310,9 +360,10 @@ async fn transient_release_absence_expires_and_refresh_interval_is_bounded() { let server = MockServer::start().await; let fixture = Fixture::new(); let service = fixture.service(&server); + mount_pointer(&server).await; Mock::given(path(INDEX_PATH)) .respond_with(ResponseTemplate::new(200).set_body_json(fixture_index(TARGET_VERSION))) - .expect(2) + .expect(1) .mount(&server) .await; let calls = AtomicUsize::new(0); @@ -323,7 +374,7 @@ async fn transient_release_absence_expires_and_refresh_interval_is_bounded() { if calls.fetch_add(1, Ordering::SeqCst) == 0 { ResponseTemplate::new(404) } else { - release_response(TARGET_VERSION, vec![]) + release_response(TARGET_VERSION, available_assets(TARGET_VERSION)) } }) .expect(3) @@ -412,7 +463,7 @@ async fn metadata_never_follows_redirects_or_accepts_oversized_release() { } else { "大小限制" })); - assert_eq!(server.received_requests().await.unwrap().len(), 2); + assert_eq!(server.received_requests().await.unwrap().len(), 3); } } @@ -422,6 +473,7 @@ async fn slow_metadata_times_out_without_retry_or_blocking_future_version_reads( let fixture = Fixture::new(); let service = Arc::new(fixture.service(&server)); let (sent, mut received) = tokio::sync::mpsc::unbounded_channel(); + mount_pointer(&server).await; Mock::given(path(INDEX_PATH)) .respond_with(move |_: &wiremock::Request| { sent.send(()).unwrap(); @@ -445,5 +497,55 @@ async fn slow_metadata_times_out_without_retry_or_blocking_future_version_reads( assert!(detail.warning.is_some()); assert!(!detail.has_update); assert_eq!(service.version().await.unwrap().version, "1.0.0"); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!(server.received_requests().await.unwrap().len(), 2); +} + +#[tokio::test] +async fn summary_and_local_probe_require_downloadable_target() { + for assets in [ + vec![], + available_assets("1.2.999"), + vec![available_assets(TARGET_VERSION).remove(0)], + { + let mut assets = available_assets(TARGET_VERSION); + assets[1]["size"] = 0.into(); + assets + }, + ] { + let server = MockServer::start().await; + let fixture = Fixture::new(); + let service = fixture.service(&server); + mount_release_json(&server, TARGET_VERSION, assets, None).await; + let summary = service + .update_detail(SystemUpdateCheck::Summary, None) + .await + .unwrap(); + assert!(summary.warning.is_some()); + assert!(!summary.has_update); + assert!(!service.version().await.unwrap().has_update); + } +} + +#[tokio::test] +async fn unpublished_or_untrusted_pointer_cannot_redirect_discovery() { + for (draft, current) in [ + (true, Some("3.42.0")), + (false, None), + (false, Some("https://example.com/v1")), + ] { + let server = MockServer::start().await; + let fixture = Fixture::new(); + let service = fixture.service(&server); + Mock::given(path(POINTER_PATH)).respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({"tag_name":"update-index","prerelease":true,"draft":draft, + "body":serde_json::json!({"current":current,"pending":{"version":"3.42.0"}}).to_string()}) + )).expect(1).mount(&server).await; + let detail = service + .update_detail(SystemUpdateCheck::Summary, None) + .await + .unwrap(); + assert!(!detail.has_update); + assert!(detail.warning.is_some()); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } } diff --git a/backend/crates/gateway-host/tests/system_update/download.rs b/backend/crates/gateway-host/tests/system_update/download.rs index fb298ebad..789d37888 100644 --- a/backend/crates/gateway-host/tests/system_update/download.rs +++ b/backend/crates/gateway-host/tests/system_update/download.rs @@ -40,6 +40,7 @@ async fn archive_is_the_last_remote_request_before_activation() { assert_eq!( paths, [ + POINTER_PATH, INDEX_PATH, &format!("/repos/owner/repository/releases/tags/v{TARGET_VERSION}"), "/checksums", diff --git a/backend/crates/gateway-host/tests/system_update/mod.rs b/backend/crates/gateway-host/tests/system_update/mod.rs index 6ccbdf009..4f7d8b954 100644 --- a/backend/crates/gateway-host/tests/system_update/mod.rs +++ b/backend/crates/gateway-host/tests/system_update/mod.rs @@ -362,7 +362,7 @@ impl Fixture { } async fn mount_release_once(&self, server: &MockServer, version: &str) { - mount_release_json(server, version, Vec::new(), Some(1)).await; + mount_release_json(server, version, available_assets(version), Some(1)).await; } async fn mount_custom_release( @@ -484,11 +484,17 @@ fn append_file( } fn archive_name(version: &str) -> String { - format!( - "codex-proxy-rs-{version}-{}-{}.tar.gz", - std::env::consts::OS, - std::env::consts::ARCH - ) + let os = if cfg!(target_os = "macos") { + "darwin" + } else { + std::env::consts::OS + }; + let arch = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "amd64" + }; + format!("codex-proxy-rs_{version}_{os}_{arch}.tar.gz") } async fn mount_release_json( @@ -509,15 +515,38 @@ async fn mount_release_json( } } -const INDEX_PATH: &str = "/owner/repository/refs/heads/main/release/version.yaml"; +const POINTER_PATH: &str = "/repos/owner/repository/releases/tags/update-index"; +const SNAPSHOT: &str = "4.0.0-beta.1"; +const INDEX_PATH: &str = "/owner/repository/releases/download/v4.0.0-beta.1/version.yaml"; + +fn pointer_response(version: &str) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tag_name":"update-index", "draft":false, "prerelease":true, + "body":serde_json::json!({"current":version}).to_string() + })) +} + +async fn mount_pointer(server: &MockServer) { + Mock::given(path(POINTER_PATH)) + .respond_with(pointer_response(SNAPSHOT)) + .mount(server) + .await; +} + +fn available_assets(version: &str) -> Vec { + [archive_name(version), "checksums.txt".to_owned()].into_iter() + .map(|name| serde_json::json!({"name":name,"size":128,"browser_download_url":"https://github.com/owner/repository/releases/download/test/file"})).collect() +} fn fixture_index(version: &str) -> serde_json::Value { let major = version.split('.').next().unwrap(); let base = version.split('-').next().unwrap(); - serde_json::json!({"published": {major: {"stable": version,"beta":version,"rc":version,"alpha":version,"exp":{base: version}}}}) + serde_json::json!({"version":SNAPSHOT, "published": {major: {"stable": version,"beta":version,"rc":version,"alpha":version,"exp":{base: version}}}}) } -async fn mount_index(server: &MockServer, index: serde_json::Value) { +async fn mount_index(server: &MockServer, mut index: serde_json::Value) { + mount_pointer(server).await; + index["version"] = SNAPSHOT.into(); Mock::given(method("GET")) .and(path(INDEX_PATH)) .respond_with(ResponseTemplate::new(200).set_body_json(index)) diff --git a/deploy/README.md b/deploy/README.md index 222fb5848..85dc25c05 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -864,10 +864,11 @@ docker compose -f deploy/compose.yaml build codex-proxy-rs 切换通道不会降级,Stable 候选也必须高于当前运行版本。 下载、安装或待重启期间不能切换通道 -更新候选来自更新仓库 main 分支 `release/version.yaml` 的 `published` 通道索引,顶层 `version` 仅表示构建版本。 -自定义仓库须提供相同索引和对应的公开 `v<版本>` Release;未配置的通道会提示原因 +更新候选来自公开发行附件。更新器按固定 tag 查询预发行页 `update-index`,从正文 JSON 的 `current` 读取快照版本,再读取该版本 Release 的 `version.yaml` 附件。附件的 `version` 标识快照,`published` 按大版本和通道列出可安装版本;修改源码或仅打 tag 不会触发提示 -查看详情或安装时按 tag 查询 Release,无需配置 GitHub 令牌。GitHub 限流时按提示稍后重试; +自定义更新仓库须提供相同入口、快照及各候选的公开 Release。使用本仓库发布工作流时自动维护索引;`update-index` 不设为 Latest,不需要额外分支 + +提示有更新前会按精确 tag 核对候选 Release、当前平台包和校验文件,无需配置 GitHub 令牌。GitHub 限流时按提示稍后重试; 也可从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载平台归档,按[手动安装](#手动安装)升级 同一 `X.Y.Z-exp.N` 系列只用于同一轮实验,不能混用不同实验分支;变更基线即进入另一实验线,需手动迁移。 diff --git a/docs/api.md b/docs/api.md index e9cd4cdd1..7feb2525d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2853,7 +2853,7 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier` `releaseUrl` 和 `notes` 保留当前版本的已发布 Release 信息;找不到匹配当前版本的 Release 时为空。 当前构建不支持在线更新时不查询 Release,`hasUpdate=false`、`latestVersion` 为当前版本, `releaseUrl` 和 `notes` 为空,不支持原因通过 `updateSupported=false`、`unsupportedReason` 返回。 -`summaryOnly=true` 优先于 `refresh`,只读发布索引,不读取 Release;`notes`、`releaseUrl` 为空。详情或摘要检查失败时返回 `warning` 和 `hasUpdate=false`,此时 `latestVersion` 不代表已确认的通道版本。GitHub 限流时,`retryAt` 为最早可重试的 RFC 3339 时间,`retryAtDisplay` 按部署时区展示;其他情况两者均为 `null`。 +`summaryOnly=true` 优先于 `refresh`,按发布索引检查版本,仅对更高候选核对公开 Release 和当前平台附件;`notes`、`releaseUrl` 为空。详情或摘要检查失败时返回 `warning` 和 `hasUpdate=false`,此时 `latestVersion` 不代表已确认的通道版本。GitHub 限流时,`retryAt` 为最早可重试的 RFC 3339 时间,`retryAtDisplay` 按部署时区展示;其他情况两者均为 `null`。 普通查询缓存 20 分钟,手动刷新最短间隔为 60 秒;临时错误缓存 60 秒,限流按来源退避。 后台安装重新核对通道和目标版本,目标变化须再次检查并确认;下载与校验流程见[在线更新](../deploy/README.md#管理端在线更新) diff --git a/docs/architecture.md b/docs/architecture.md index d3926a877..09a136d9c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1294,9 +1294,9 @@ Host 在受理时持久化任务并转交后台执行,任务持有操作锁与 二进制、Web 资源、官方插件目录及回滚备份须整体匹配,无法核实的备份不参与回滚 通道是单次查询或安装的参数,不持久化偏好,默认按运行版本推导。 -Host 从更新仓库 main 分支的 `release/version.yaml` 读取 `published` 候选,运行版本来自本地构建。 -每个实例共享一份索引,按精确 tag 缓存至多五份 Release 详情;版本接口只读本地状态,摘要查询读取索引,详情查询才读取 Release。 -查询合并并发读取,索引和 API 各自限流;单轮元数据请求含排队最多 20 秒,索引限 8 KiB,Release 限 4 MiB。 +Host 按固定 tag `update-index` 读取公开快照版本,只从该版本的 `version.yaml` 附件选择候选。发布工作流在仓库级发布锁内合并通道,校验公开 Release 及附件后才推进指针;待发布记录用于中断恢复,不参与客户端选版。 +实例复用同版本快照,按精确 tag 缓存至多五份 Release 详情。版本接口只读本地状态,摘要与详情都须核对更高候选的发行身份、平台包和校验文件。 +查询合并并发读取,附件和 API 各自限流;单轮元数据请求含排队最多 20 秒,索引附件限 128 KiB,Release 限 4 MiB。 安装在下载前固定通道、目标和校验值,安装包是最后一项远端资源;校验、替换和重启只依赖本地发行文件。 Admin/API 只转发策略事实,前端分别展示运行版本、通道候选与待生效版本