From 2a8d5d86436c895f29b08ae06053dcc21096f142 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 7 Nov 2026 00:13:47 +0800 Subject: [PATCH] ci(release): publish the channel index after verifying release assets Co-authored-by: Codex --- .agents/skills/cpr-release/SKILL.md | 1 + .../cpr-release/references/experimental.md | 2 +- .../cpr-release/references/operations.md | 6 +- .../cpr-release/references/preparation.md | 2 +- .github/scripts/package-release.rb | 6 +- .github/scripts/published-channels.rb | 82 ++++++++ .github/scripts/release-checks-test.rb | 193 ------------------ .github/scripts/release-metadata.rb | 4 + .github/workflows/_quality.yml | 3 - .github/workflows/release.yml | 6 + release/version.yaml | 6 + 11 files changed, 109 insertions(+), 202 deletions(-) create mode 100644 .github/scripts/published-channels.rb delete mode 100644 .github/scripts/release-checks-test.rb diff --git a/.agents/skills/cpr-release/SKILL.md b/.agents/skills/cpr-release/SKILL.md index 56b77cfc6..39558528d 100644 --- a/.agents/skills/cpr-release/SKILL.md +++ b/.agents/skills/cpr-release/SKILL.md @@ -20,6 +20,7 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行 - Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替 - `release/publish --check ` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权 - 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突 +- 备版只更新 `release/version.yaml` 的 `version`;工作流确认 Release 公开、附件齐全后才推进 `main` 上的 `published`,验收时分别核对 - 预发行不得覆盖 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 ff67e4c71..3a6362b7a 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`。 - 正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。 + 发布任务应将该基线的最高公开实验版写入 main 的 `published[大版本].exp[基线]`,不推进普通通道。 - 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。 对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。 - 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时, diff --git a/.agents/skills/cpr-release/references/operations.md b/.agents/skills/cpr-release/references/operations.md index 7bed63ace..24b7ade58 100644 --- a/.agents/skills/cpr-release/references/operations.md +++ b/.agents/skills/cpr-release/references/operations.md @@ -20,7 +20,7 @@ 调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。 先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。 -脚本会更新 `release/version.yaml`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。 +脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。 不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。 本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。 @@ -46,5 +46,9 @@ 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,接近上限时核实并移除已结束维护的实验基线,不删仍受支持的通道 - 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功; Release 和镜像已生成也不表示用户的运行实例已经升级。 diff --git a/.agents/skills/cpr-release/references/preparation.md b/.agents/skills/cpr-release/references/preparation.md index 910c7f2ba..e77626584 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,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交 +2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分备版字段 `version` 和已发布通道表 `published`,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交 3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突 4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本) diff --git a/.github/scripts/package-release.rb b/.github/scripts/package-release.rb index b128c4674..6b5744506 100644 --- a/.github/scripts/package-release.rb +++ b/.github/scripts/package-release.rb @@ -1,10 +1,10 @@ require 'digest' require 'fileutils' -require 'json' +require_relative 'release-metadata' # 归档与镜像消费相同二进制和封口清单,不在打包时重新解释插件分发策略 module ReleasePackage - APP_NAME = 'codex-proxy-rs' + APP_NAME = ReleaseMetadata::APP_NAME MANIFEST = 'dist/plugin-release-assets/plugin-release-manifest.json' def self.install_manifest(directory) @@ -47,7 +47,7 @@ module ReleasePackage FileUtils.cp_r('dist/frontend/.', "#{root}/web/dist") FileUtils.cp(config, "#{root}/deploy") install_manifest("#{root}/plugins/official") - archive = "#{release_dir}/#{APP_NAME}_#{ENV.fetch('VERSION')}_#{platform['os']}_#{platform['arch']}.tar.gz" + archive = File.join(release_dir, ReleaseMetadata.archive_name(ENV.fetch('VERSION'), platform)) raise "Failed to create #{archive}" unless system('tar', '-czf', archive, '-C', File.dirname(root), APP_NAME) end diff --git a/.github/scripts/published-channels.rb b/.github/scripts/published-channels.rb new file mode 100644 index 000000000..f07f6f8cd --- /dev/null +++ b/.github/scripts/published-channels.rb @@ -0,0 +1,82 @@ +require 'base64' +require 'uri' +require_relative 'release-metadata' + +# 所有发行线只推进 main 中已公开可下载的候选,保留备版字段 +module PublishedChannels + PATH = 'release/version.yaml' + LIMIT = 8192 + + 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 + + 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) + 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) + 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 + 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)) +end diff --git a/.github/scripts/release-checks-test.rb b/.github/scripts/release-checks-test.rb deleted file mode 100644 index 5c931b7bc..000000000 --- a/.github/scripts/release-checks-test.rb +++ /dev/null @@ -1,193 +0,0 @@ -require 'fileutils' -require 'json' -require 'open3' -require 'tmpdir' -require_relative 'release-quality' - -# 使用临时 Git 仓库验证远端状态和只读边界,不访问真实 GitHub 或修改当前仓库 -ROOT = File.expand_path('../..', __dir__) - -def assert(condition, message) - raise message unless condition -end - -def git(*arguments) - output, error, status = Open3.capture3('git', *arguments) - raise "git #{arguments.first}: #{error}" unless status.success? - output.strip -end - -def commit(message) - git('add', '.') - git('commit', '-qm', message) -end - -Dir.mktmpdir('release-checks-') do |directory| - remote = File.join(directory, 'remote.git') - system('git', 'init', '--bare', '-q', remote, exception: true) - checkout = File.join(directory, 'checkout') - FileUtils.mkdir_p(checkout) - Dir.chdir(checkout) do - git('init', '-q', '-b', 'main') - git('config', 'user.email', 'test@example.invalid') - git('config', 'user.name', 'Release test') - files = %w[ - release/publish release/version.yaml release/notes.md release/platforms.yaml - .github/scripts/toolchains.rb .github/scripts/release-metadata.rb - backend/rust-toolchain.toml deploy/Dockerfile - backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb - backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json - backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json - ] - files.each do |path| - FileUtils.mkdir_p(File.dirname(path)) - FileUtils.cp(File.join(ROOT, path), path) - end - commit('initial source') - assert(ReleaseQuality.evidence('owner/repo').nil?, 'root commit should require full checks') - git('remote', 'add', 'origin', remote) - git('push', '-qu', 'origin', 'main') - File.write('release/notes.md', "# v99.0.0\n\nFixture notes\n") - - bin = File.join(directory, 'bin') - FileUtils.mkdir_p(bin) - File.write(File.join(bin, 'gh'), <<~RUBY) - #!/usr/bin/env ruby - case ARGV - when ['auth', 'status'] then exit 0 - when ['repo', 'view', #{remote.inspect}, '--json', 'nameWithOwner', '--jq', '.nameWithOwner'] - puts 'owner/repo' - else - abort "unexpected gh mutation: \#{ARGV.inspect}" - end - RUBY - File.chmod(0o755, File.join(bin, 'gh')) - environment = { 'PATH' => "#{bin}:#{ENV.fetch('PATH')}" } - snapshot = lambda do - [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index'), - files.to_h { |path| [path, File.binread(path)] }, - git('ls-remote', 'origin'), File.exist?('.git/FETCH_HEAD')] - end - check = lambda do |success, message = nil| - before = snapshot.call - output, error, status = Open3.capture3(environment, 'bash', 'release/publish', '--check', '99.0.0') - assert(status.success? == success, "preflight outcome: #{output}#{error}") - assert("#{output}#{error}".include?(message), "missing error: #{message}; #{error}") if message - assert(snapshot.call == before, 'preflight modified files, refs, index, or FETCH_HEAD') - end - check.call(true, 'Preflight passed') - File.write('deploy/Dockerfile', File.read('deploy/Dockerfile') + "\n# fixture\n") - check.call(false, 'unstaged changes') - git('checkout', '--', 'deploy/Dockerfile') - git('tag', 'v99.0.0') - check.call(false, 'local tag already exists') - git('push', '-q', 'origin', 'refs/tags/v99.0.0') - git('tag', '-d', 'v99.0.0') - check.call(false, 'remote tag already exists') - git('push', '-q', 'origin', ':refs/tags/v99.0.0') - - # 未履行的兼容窗口必须在只读预检中失败,不能等待提交版本后再发现 - deprecations = 'backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json' - previous = File.read(deprecations) - File.write(deprecations, JSON.generate([{ - capability: 'removed-fixture', version: 1, replacement_version: 2, remaining_releases: 7 - }])) - git('add', deprecations) - git('commit', '-qm', 'invalid lifecycle fixture') - check.call(false, 'replacement v2 must be available') - File.write(deprecations, previous) - commit('restore lifecycle and notes') - File.write('release/notes.md', "# v99.0.0\n\nUpdated fixture notes\n") - - # 远端前进但没有本地对象时,提示同步而不隐式 fetch - other = File.join(directory, 'other') - git('clone', '-q', '-b', 'main', remote, other) - Dir.chdir(other) do - git('config', 'user.email', 'test@example.invalid') - git('config', 'user.name', 'Release test') - File.write('remote-only', 'change') - commit('remote advance') - git('push', '-q') - end - check.call(false, 'unseen commits') - git('fetch', '-q', 'origin') - check.call(false, 'diverged') - FileUtils.mv(remote, "#{remote}.hidden") - # 读取远端失败不能当作 tag 不存在,仍须保持本地不变 - before = [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index')] - _, error, status = Open3.capture3(environment, 'bash', 'release/publish', '--check', '99.0.0') - assert(!status.success? && error.include?('failed to read remote refs'), 'remote failure was ignored') - assert(before == [git('show-ref'), git('status', '--porcelain'), File.binread('.git/index')], 'remote failure changed local state') - FileUtils.mv("#{remote}.hidden", remote) - - File.write('release/version.yaml', "version: \"99.0.0\"\n") - commit('release metadata') - parent = git('rev-parse', 'HEAD^') - run = { 'head_sha' => parent, 'conclusion' => 'success', 'head_repository' => { 'full_name' => 'owner/repo' }, 'id' => 42 } - job = { 'name' => 'quality / backend', 'conclusion' => 'success', 'html_url' => 'https://example.invalid/42' } - runs = [run] - jobs = [job] - original_capture = ReleaseQuality.method(:capture) - ReleaseQuality.define_singleton_method(:capture) do |*arguments| - if arguments.first == 'gh' - endpoint = arguments.find { |argument| argument.start_with?('repos/') } - if endpoint.include?('/workflows/ci.yml/runs?') - assert(endpoint.include?("event=push&branch=main&head_sha=#{parent}"), 'wrong CI evidence scope') - JSON.generate('workflow_runs' => runs) - else - assert(endpoint == 'repos/owner/repo/actions/runs/42/jobs?per_page=100', 'wrong run jobs') - jobs.map { |entry| JSON.generate(entry) }.join("\n") - end - else - original_capture.call(*arguments) - end - end - assert(ReleaseQuality.evidence('owner/repo') == job['html_url'], 'valid CI evidence not reused') - %w[skipped failure cancelled].each do |conclusion| - job['conclusion'] = conclusion - assert(ReleaseQuality.evidence('owner/repo').nil?, "reused #{conclusion} backend") - end - job['conclusion'] = 'success' - jobs = [] - assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused missing backend job') - jobs = [job] - run['conclusion'] = 'failure' - assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused failed CI run') - run['conclusion'] = nil - assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused unfinished CI run') - run['conclusion'] = 'success' - run['head_repository']['full_name'] = 'fork/repo' - assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused foreign repository CI') - run['head_repository']['full_name'] = 'owner/repo' - runs = [] - assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused absent CI run') - runs = [run] - output, _, status = Open3.capture3(environment.merge('GITHUB_REPOSITORY' => 'owner/repo'), 'ruby', File.join(ROOT, '.github/scripts/release-quality.rb')) - assert(status.success? && output.strip == 'run_backend=true', 'API failure did not fall back to full checks') - File.write('backend/source.rs', '// changed source') - commit('source changed') - assert(ReleaseQuality.evidence('owner/repo').nil?, 'reused CI across source changes') - merge = git('commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-p', 'HEAD^', '-m', 'merge fixture') - git('update-ref', 'HEAD', merge) - assert(ReleaseQuality.evidence('owner/repo').nil?, 'merge commit should require full checks') - - # 在同一个隔离仓库走实际提交与原子推送路径,dispatch 仍由本地替身接收 - git('merge', '--no-edit', 'origin/main') - File.write('release/notes.md', "# v100.0.0\n\nPublish fixture notes\n") - dispatch = File.join(directory, 'dispatch.json') - mock = File.join(bin, 'gh') - File.write(mock, File.read(mock).sub("else\n", <<~RUBY)) - when ['workflow', 'run', 'release.yml', '--repo', 'owner/repo', '--ref', 'main', '--raw-field', 'tag=v100.0.0'] - File.write(#{dispatch.inspect}, ARGV.join(' ')) - else - RUBY - output, error, status = Open3.capture3(environment, 'bash', 'release/publish', '100.0.0') - assert(status.success?, "publish failed: #{output}#{error}") - assert(File.exist?(dispatch), 'workflow was not dispatched') - assert(git('log', '-1', '--format=%s') == 'chore(release): v100.0.0', 'wrong release commit') - assert(git('status', '--porcelain').empty?, 'release left tracked changes') - assert(git('rev-parse', 'HEAD') == git('rev-parse', 'v100.0.0^{commit}'), 'tag differs from release commit') - assert(git('ls-remote', 'origin', 'refs/tags/v100.0.0').split.first == git('rev-parse', 'v100.0.0'), 'tag not pushed') - end -end -puts 'Release preflight and CI evidence checks passed' diff --git a/.github/scripts/release-metadata.rb b/.github/scripts/release-metadata.rb index c1e066b8c..2229dde8e 100644 --- a/.github/scripts/release-metadata.rb +++ b/.github/scripts/release-metadata.rb @@ -62,6 +62,10 @@ module ReleaseMetadata entries end + def self.archive_name(version, platform) + "#{APP_NAME}_#{version}_#{platform.fetch('os')}_#{platform.fetch('arch')}.#{platform.fetch('archive')}" + end + def self.outputs(tag) value = version(YAML.load_file('release/version.yaml').fetch('version')) raise "Release tag #{tag} does not match v#{value}" unless tag == "v#{value}" diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index 5fe8a44e2..cc5ba7035 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -259,6 +259,3 @@ jobs: for script in .github/scripts/*.rb; do ruby -c "$script"; done ruby .github/scripts/toolchains.rb ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb - - - name: Verify release preflight and CI evidence - run: ruby .github/scripts/release-checks-test.rb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 384bd0b51..d17f0dcd2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -563,3 +563,9 @@ jobs: DIGEST: ${{ steps.manifest.outputs.digest }} RELEASE_ID: ${{ steps.release.outputs.id }} run: ruby .github/scripts/publish-release.rb + + - name: Advance published channels on main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.release-info.outputs.version }} + run: ruby .github/scripts/published-channels.rb "$VERSION" diff --git a/release/version.yaml b/release/version.yaml index 23dfbbfda..f3e1b6fae 100644 --- a/release/version.yaml +++ b/release/version.yaml @@ -1 +1,7 @@ version: "3.41.2" +published: + "3": + stable: "3.41.2" + rc: "3.41.2" + beta: "3.41.2" + alpha: "3.41.2"