ci: streamline builds and release validation

Share binary builds and dependency caches, run Windows platform checks in parallel, and reuse verified CI evidence for version-only releases. Reuse prebuilt CI image inputs and add read-only release preflight.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-05 13:03:59 +08:00
co-authored by Codex
parent 9039cef3d8
commit b9bcb4e086
19 changed files with 589 additions and 273 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
## 必须保持的边界
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
@@ -31,7 +31,7 @@ release/publish "$release_version"
```
该命令会创建上述版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只读预检使用 `release/publish --check "$release_version"`。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
@@ -43,6 +43,7 @@ release/publish "$release_version"
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
@@ -26,6 +26,9 @@
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
+76
View File
@@ -0,0 +1,76 @@
name: Build binary
description: Build a native release binary with shared dependency caching and timings
inputs:
rust:
description: Pinned Rust toolchain
required: true
target:
description: Rust target triple
required: true
platform:
description: Release platform name, such as linux-amd64
required: true
binary:
description: Executable filename
default: codex-proxy-rs
rust-image:
description: Pinned Linux builder image; empty for native builds
default: ''
runs:
using: composite
steps:
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ inputs.rust }}
targets: ${{ inputs.target }}
- name: Configure Windows runtime linkage
if: runner.os == 'Windows'
shell: bash
# 发行包静态链接 MSVC 运行库,解压运行时无需安装 VC++ Redistributable
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
shared-key: release-${{ inputs.target }}-${{ inputs.rust-image }}
cache-bin: false
# 只缓存依赖,避免随版本变化的 workspace 产物挤占缓存
save-if: ${{ github.event_name != 'pull_request' }}
- name: Build binary
shell: bash
working-directory: backend
env:
BUILD_TARGET: ${{ inputs.target }}
BUILD_PLATFORM: ${{ inputs.platform }}
BUILD_BINARY: ${{ inputs.binary }}
RUST_IMAGE: ${{ inputs.rust-image }}
run: |
set -euo pipefail
if [ -n "$RUST_IMAGE" ]; then
mkdir -p "$HOME/.cargo"
docker run --rm --user "$(id -u):$(id -g)" \
-e CARGO_HOME=/cargo -e HOME=/tmp \
-e CPR_VERSION -e CPR_GIT_SHA -e CPR_BUILD_TIME -e CPR_BUILD_TYPE \
-v "$HOME/.cargo:/cargo" -v "$PWD/..:/workspace" \
-w /workspace/backend "$RUST_IMAGE" \
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
else
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
fi
"target/$BUILD_TARGET/release/$BUILD_BINARY" --help
"target/$BUILD_TARGET/release/$BUILD_BINARY" --version
mkdir -p "../dist/binaries/$BUILD_PLATFORM"
cp "target/$BUILD_TARGET/release/$BUILD_BINARY" "../dist/binaries/$BUILD_PLATFORM/$BUILD_BINARY"
- name: Upload build timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: build-timings-${{ inputs.platform }}
path: backend/target/cargo-timings/
if-no-files-found: ignore
retention-days: 7
+1 -1
View File
@@ -43,4 +43,4 @@ runs:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
retention-days: 7
-24
View File
@@ -1,24 +0,0 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS restore
ARG CACHE_STAMP
RUN --mount=type=bind,target=/cache \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& tar -xzf /cache/registry.tar.gz -C /usr/local/cargo/registry \
&& tar -xzf /cache/git.tar.gz -C /usr/local/cargo/git \
&& tar -xzf /cache/target.tar.gz -C /app/backend/target
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS snapshot
ARG CACHE_STAMP
# 直接压缩 mount 内容,构建层和 runner 只保存归档,避免多份展开的编译产物占满磁盘
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& mkdir -p /cache \
&& tar -czf /cache/registry.tar.gz -C /usr/local/cargo/registry . \
&& tar -czf /cache/git.tar.gz -C /usr/local/cargo/git . \
&& tar -czf /cache/target.tar.gz -C /app/backend/target .
-31
View File
@@ -1,31 +0,0 @@
name: Cargo cache mounts
description: Restore or export a validated Cargo snapshot for a BuildKit builder
inputs:
operation:
description: Cache operation, restore or snapshot
required: true
builder:
description: Buildx builder name
required: true
path:
description: Cargo snapshot directory on the runner
required: true
architecture:
description: Cargo target directory architecture, amd64 or arm64
required: true
outputs:
usable:
description: Whether the restored or exported snapshot contains Cargo dependencies
value: ${{ steps.cache.outputs.usable }}
runs:
using: composite
steps:
- id: cache
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
OPERATION: ${{ inputs.operation }}
BUILDER: ${{ inputs.builder }}
CACHE_PATH: ${{ inputs.path }}
ARCHITECTURE: ${{ inputs.architecture }}
run: bash "$ACTION_PATH/cargo-cache.sh" "$OPERATION" "$BUILDER" "$CACHE_PATH" "$ARCHITECTURE"
@@ -1,78 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 4 ]; then
echo "usage: $0 restore|snapshot BUILDER DIRECTORY amd64|arm64" >&2
exit 2
fi
operation="$1"
builder="$2"
directory="$(realpath -m "$3")"
architecture="$4"
case "$operation" in restore|snapshot) ;; *) exit 2 ;; esac
case "$architecture" in amd64|arm64) ;; *) exit 2 ;; esac
dockerfile="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Dockerfile"
usable() {
# 流式检查归档中的非空依赖文件,校验时不在 runner 展开编译产物
python3 - "$1" "$architecture" <<'PY'
import pathlib
import re
import sys
import tarfile
root = pathlib.Path(sys.argv[1])
architecture = sys.argv[2]
def contains(filename, pattern):
with tarfile.open(root / filename, mode="r|gz") as archive:
return any(
member.isfile() and member.size > 0
and re.fullmatch(pattern, member.name.removeprefix("./"))
for member in archive
)
try:
valid = (
(root / "git.tar.gz").stat().st_size > 0
and contains("registry.tar.gz", r"cache/[^/]+/[^/]+\.crate")
and contains("target.tar.gz", rf"{architecture}/release/deps/[^/]+\.rlib")
)
except (OSError, tarfile.TarError):
valid = False
sys.exit(0 if valid else 1)
PY
}
command=(docker buildx build --builder "$builder" --file "$dockerfile" --progress plain)
# mount 的内容不参与层指纹,用时间戳重跑归档操作;--no-cache 会重置 cache mount
command+=(--build-arg "CACHE_STAMP=$(date +%s%N)" --target "$operation")
valid=false
if [ "$operation" = restore ]; then
if usable "$directory"; then
"${command[@]}" --output type=cacheonly "$directory"
valid=true
else
echo "No usable Cargo snapshot; cached image layers may still be available"
fi
else
# 导出只发送空上下文,新快照独立校验后替换旧目录,避免残留文件造成误判。
mkdir -p "$(dirname "$directory")"
temporary="$(mktemp -d "${directory}.XXXXXX")"
trap 'rm -rf "$temporary"' EXIT
mkdir "$temporary"/{context,snapshot}
# tar 流避开 local exporter 的逐文件同步阻塞,只接收 /cache 下的压缩归档
# 直接导出打包阶段,避免再创建一层归档副本
# https://github.com/moby/buildkit/issues/2950
"${command[@]}" --output type=tar,dest=- "$temporary/context" \
| tar -xf - -C "$temporary/snapshot" --strip-components=1 cache/
if usable "$temporary/snapshot"; then
rm -rf "$directory"
mv "$temporary/snapshot" "$directory"
valid=true
fi
fi
echo "Cargo snapshot for $architecture: usable=$valid"
if [ "$valid" = true ]; then du -sh "$directory"; fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'usable=%s\n' "$valid" >> "$GITHUB_OUTPUT"; fi
-1
View File
@@ -70,7 +70,6 @@ updates:
- package-ecosystem: docker
directories:
- /deploy
- /.github/actions/cargo-cache
labels: []
schedule:
interval: weekly
+193
View File
@@ -0,0 +1,193 @@
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'
+1
View File
@@ -82,6 +82,7 @@ module ReleaseMetadata
'platform_matrix' => JSON.generate('include' => entries),
'docker_matrix' => JSON.generate('include' => docker),
'non_docker_matrix' => JSON.generate('include' => native),
'windows_matrix' => JSON.generate('include' => entries.select { |entry| entry['os'] == 'windows' }),
'docker_platforms' => docker.map { |entry| entry['dockerPlatform'] }.join(','),
'previous_tag' => previous,
'prerelease' => prerelease?(value),
+44
View File
@@ -0,0 +1,44 @@
require 'json'
require 'open3'
# 仅复用同一源码的主分支 CI,缺少证据时完整检查发行提交
module ReleaseQuality
def self.capture(*command)
output, error, status = Open3.capture3(*command)
raise "#{command.first} failed: #{error.strip}" unless status.success?
output
end
def self.evidence(repository)
parents = capture('git', 'rev-list', '--parents', '-n', '1', 'HEAD').split
return nil unless parents.length == 2
parent = parents.last
changes = capture('git', 'diff', '--name-only', '-z', parent, 'HEAD').split("\0")
return nil if changes.empty? || (changes - %w[release/version.yaml release/notes.md]).any?
# 按最新一次运行判断,失败或仍在执行时不回退到旧的成功记录
endpoint = "repos/#{repository}/actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=#{parent}&per_page=1"
run = JSON.parse(capture('gh', 'api', endpoint)).fetch('workflow_runs').first
return nil unless run && run.fetch('head_sha') == parent && run.fetch('conclusion') == 'success'
return nil unless run.fetch('head_repository').fetch('full_name') == repository
rows = capture('gh', 'api', '--paginate', "repos/#{repository}/actions/runs/#{run.fetch('id')}/jobs?per_page=100", '--jq', '.jobs[] | @json')
job = rows.lines.map { |line| JSON.parse(line) }.find { |entry| entry.fetch('name') == 'quality / backend' }
return nil unless job && job.fetch('conclusion') == 'success'
job.fetch('html_url')
end
end
if $PROGRAM_NAME == __FILE__
begin
evidence = ReleaseQuality.evidence(ENV.fetch('GITHUB_REPOSITORY'))
rescue StandardError => error
warn "CI evidence unavailable; running full release checks: #{error.message}"
evidence = nil
end
puts "run_backend=#{evidence.nil?}"
if ENV['GITHUB_STEP_SUMMARY']
message = evidence ? "后端质量检查复用版本提交父提交的 [CI 结果](#{evidence});仅版本与发布说明不同" : '未找到可复用的后端 CI 结果,完整检查发行提交'
File.open(ENV.fetch('GITHUB_STEP_SUMMARY'), 'a') { |file| file.puts(message) }
end
end
+83 -40
View File
@@ -7,13 +7,58 @@ on:
description: Exact commit SHA to build and validate
required: true
type: string
source_build:
description: Validate the complete Docker source build
type: boolean
default: true
permissions:
contents: read
security-events: write
jobs:
frontend:
if: ${{ !inputs.source_build }}
runs-on: ubuntu-26.04
timeout-minutes: 15
env:
CI: 'true'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
- uses: ./.github/actions/build-frontend
with:
upload-artifact: 'true'
binary:
if: ${{ !inputs.source_build }}
runs-on: ubuntu-26.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
- id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- uses: ./.github/actions/build-binary
with:
rust: ${{ steps.toolchains.outputs.rust }}
target: x86_64-unknown-linux-gnu
platform: linux-amd64
rust-image: ${{ steps.toolchains.outputs.rust_image }}
env:
CPR_GIT_SHA: ${{ inputs.ref }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: codex-proxy-rs-linux-amd64
path: dist/binaries/linux-amd64/
if-no-files-found: error
retention-days: 7
container:
needs: [frontend, binary]
if: always() && !cancelled()
runs-on: ubuntu-26.04
timeout-minutes: 45
env:
@@ -21,6 +66,14 @@ jobs:
TRIVY_QUIET: 'true'
TRIVY_SKIP_VERSION_CHECK: 'true'
steps:
# 上游失败必须传递给已有的容器检查,不能让必需状态变成 skipped
- name: Require prebuilt inputs
if: ${{ !inputs.source_build }}
env:
FRONTEND_RESULT: ${{ needs.frontend.result }}
BINARY_RESULT: ${{ needs.binary.result }}
run: test "$FRONTEND_RESULT" = success && test "$BINARY_RESULT" = success
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -30,41 +83,51 @@ jobs:
id: buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Restore Cargo snapshot
id: cargo-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Download frontend
if: ${{ !inputs.source_build }}
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: ${{ runner.temp }}/container-cargo
# 同一平台和依赖复用快照,源码变化由 Cargo 指纹判定是否重编译
key: container-cargo-v4-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', 'deploy/Dockerfile', '.github/actions/cargo-cache/Dockerfile') }}
restore-keys: |
container-cargo-v4-${{ runner.os }}-${{ runner.arch }}-
name: frontend-dist
path: dist/docker/web/dist
- name: Restore Cargo mounts to BuildKit
id: cargo-restore
uses: ./.github/actions/cargo-cache
- name: Download binary
if: ${{ !inputs.source_build }}
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
operation: restore
builder: ${{ steps.buildx.outputs.name }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
name: codex-proxy-rs-linux-amd64
path: dist/docker-artifacts/codex-proxy-rs-linux-amd64
- name: Release restored Cargo snapshot
run: rm -rf "$RUNNER_TEMP/container-cargo"
- name: Prepare prebuilt image
if: ${{ !inputs.source_build }}
env:
MATRIX_OS: linux
MATRIX_ARCH: amd64
MATRIX_BINARY: codex-proxy-rs
run: |
ruby -r './.github/scripts/release-metadata' -e '
ReleaseMetadata.seal_manifest(
"version" => ReleaseMetadata.version(YAML.load_file("release/version.yaml").fetch("version")),
"git_sha" => ReleaseMetadata.git("rev-parse", "HEAD")
)'
ruby .github/scripts/package-release.rb image
- name: Build runtime image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: deploy/Dockerfile
target: runtime
target: ${{ inputs.source_build && 'runtime' || 'runtime-prebuilt' }}
build-args: |
CPR_PREBUILT_DIR=dist/docker
CPR_GIT_SHA=${{ inputs.ref }}
load: true
tags: ${{ env.CPR_IMAGE }}
no-cache-filters: |
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
runtime-prebuilt
cache-from: type=gha,scope=runtime-${{ inputs.source_build && 'source' || 'prebuilt' }}
cache-to: ${{ (github.event_name == 'push' || github.event_name == 'schedule') && github.ref == 'refs/heads/main' && format('type=gha,mode=max,scope=runtime-{0}', inputs.source_build && 'source' || 'prebuilt') || '' }}
- name: Verify runtime image
uses: ./.github/actions/verify-container
@@ -72,23 +135,3 @@ jobs:
image: ${{ env.CPR_IMAGE }}
platform: linux/amd64
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
# 构建层命中时 mount 仍可能为空,只有实际检查过内容的快照才允许保存。
- name: Export Cargo snapshot
if: >-
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
(steps.cargo-cache.outputs.cache-hit != 'true' || steps.cargo-restore.outputs.usable != 'true')
id: cargo-snapshot
uses: ./.github/actions/cargo-cache
with:
operation: snapshot
builder: ${{ steps.buildx.outputs.name }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
- name: Save Cargo snapshot
if: steps.cargo-snapshot.outputs.usable == 'true' && steps.cargo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/container-cargo
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
+22 -4
View File
@@ -28,6 +28,7 @@ jobs:
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
CARGO_PROFILE_DEV_DEBUG: '1'
# 编译 AWS SDK 调试信息需要更大的 codegen 线程栈
RUST_MIN_STACK: 16777216
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
@@ -111,7 +112,9 @@ jobs:
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
shared-key: quality
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
- name: Rust format
working-directory: backend
@@ -131,7 +134,7 @@ jobs:
working-directory: backend
run: |
cargo metadata --format-version 1 --locked > "$RUNNER_TEMP/nextest-cargo-metadata.json"
cargo nextest list --test main --locked --list-type binaries-only --message-format json \
cargo nextest list --test main --locked --timings --list-type binaries-only --message-format json \
> "$RUNNER_TEMP/nextest-binaries.json"
- name: Configure PostgreSQL and Redis test authentication
@@ -173,10 +176,22 @@ jobs:
- name: Rust tests
working-directory: backend
run: |
cargo nextest run \
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" \
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
--test-threads 8 --no-tests fail
--test-threads 8 --no-tests fail --profile ci
- name: Upload test and build reports
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: backend-quality-reports
path: |
backend/target/nextest/ci/junit.xml
backend/target/cargo-timings/
if-no-files-found: ignore
retention-days: 7
- name: Store tests with Redis ACL authentication
working-directory: backend
@@ -244,3 +259,6 @@ 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
+25 -2
View File
@@ -29,6 +29,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
container: ${{ steps.filter.outputs.container }}
source_container: ${{ steps.filter.outputs.source_container }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -67,10 +68,30 @@ jobs:
- '.dockerignore'
- 'release/version.yaml'
- '.github/actions/verify-container/**'
- '.github/actions/cargo-cache/**'
- '.github/actions/build-binary/**'
- '.github/actions/build-frontend/**'
- '.github/scripts/toolchains.rb'
- '.github/scripts/package-release.rb'
- '.github/scripts/release-metadata.rb'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
- '!**/*.md'
source_container:
- 'deploy/Dockerfile'
- 'deploy/Dockerfile.dockerignore'
- '.dockerignore'
- 'backend/**/Cargo.toml'
- 'backend/Cargo.lock'
- 'backend/rust-toolchain.toml'
- 'backend/**/build.rs'
- 'backend/.cargo/**'
- 'frontend/package.json'
- 'frontend/pnpm-lock.yaml'
- 'frontend/pnpm-workspace.yaml'
- 'frontend/.npmrc'
- 'frontend/patches/**'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
quality:
needs: changes
@@ -78,7 +99,8 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
run_backend: ${{ needs.changes.outputs.backend == 'true' }}
run_frontend: ${{ needs.changes.outputs.frontend == 'true' }}
# 预构建容器复用同一次前端检查产物,源码 Docker 构建仍独立验证
run_frontend: ${{ needs.changes.outputs.frontend == 'true' && (needs.changes.outputs.container != 'true' || needs.changes.outputs.source_container == 'true') }}
container:
needs: changes
@@ -89,3 +111,4 @@ jobs:
uses: ./.github/workflows/_container.yml
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
source_build: ${{ needs.changes.outputs.source_container == 'true' }}
+91 -79
View File
@@ -23,6 +23,9 @@ jobs:
release-info:
runs-on: ubuntu-26.04
timeout-minutes: 10
permissions:
contents: read
actions: read
outputs:
tag: ${{ steps.version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
@@ -32,12 +35,14 @@ jobs:
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
docker_matrix: ${{ steps.version.outputs.docker_matrix }}
non_docker_matrix: ${{ steps.version.outputs.non_docker_matrix }}
windows_matrix: ${{ steps.version.outputs.windows_matrix }}
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
build_type: ${{ steps.version.outputs.build_type }}
rust: ${{ steps.toolchains.outputs.rust }}
rust_image: ${{ steps.toolchains.outputs.rust_image }}
run_backend: ${{ steps.quality.outputs.run_backend }}
steps:
- name: Checkout
@@ -56,21 +61,88 @@ jobs:
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Check reusable backend CI evidence
id: quality
env:
GH_TOKEN: ${{ github.token }}
run: ruby .github/scripts/release-quality.rb >> "$GITHUB_OUTPUT"
- name: Upload sealed plugin assets
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: plugin-release-assets
path: dist/plugin-release-assets
if-no-files-found: error
retention-days: 1
retention-days: 7
quality:
needs: [release-info]
uses: ./.github/workflows/_quality.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
run_backend: ${{ needs.release-info.outputs.run_backend == 'true' }}
run_frontend: false
windows:
needs: release-info
name: windows-tests (${{ matrix.arch }})
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.windows_matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
env:
RUST_MIN_STACK: '16777216'
RUSTFLAGS: '-C target-feature=+crt-static'
CARGO_PROFILE_DEV_DEBUG: '1'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: backend -> target
shared-key: platform-tests-${{ matrix.target }}
cache-bin: false
save-if: ${{ github.event_name != 'pull_request' }}
- name: Setup nextest
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: cargo-nextest@0.9.143
fallback: none
- name: Verify Windows platform behavior
shell: bash
working-directory: backend
env:
BUILD_TARGET: ${{ matrix.target }}
# 同一次构建统一两个 package 的依赖特性,保留原有平台用例和静态运行库
run: |
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" --locked --target "$BUILD_TARGET" \
-p gateway-admin -p gateway-host --test main --no-tests fail --profile ci \
-E '(package(=gateway-admin) & test(/^use_case::plugins::official::/)) | (package(=gateway-host) & test(/^system_update::/))'
- name: Upload test report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: windows-tests-${{ matrix.arch }}
path: backend/target/nextest/ci/junit.xml
if-no-files-found: ignore
retention-days: 7
security:
needs: release-info
uses: ./.github/workflows/security.yml
permissions:
contents: read
security-events: write
with:
ref: ${{ needs.release-info.outputs.git_sha }}
frontend:
needs: release-info
runs-on: ubuntu-26.04
@@ -103,40 +175,15 @@ jobs:
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
backend/target
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ needs.release-info.outputs.rust_image }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
run: |
mkdir -p "$HOME/.cargo" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
docker run --rm \
--platform "${{ matrix.dockerPlatform }}" \
--user "$(id -u):$(id -g)" \
-e CARGO_HOME=/cargo \
-e HOME=/tmp \
-e CPR_VERSION="${CPR_VERSION}" \
-e CPR_GIT_SHA="${CPR_GIT_SHA}" \
-e CPR_BUILD_TIME="${CPR_BUILD_TIME}" \
-e CPR_BUILD_TYPE="${CPR_BUILD_TYPE}" \
-v "$HOME/.cargo:/cargo" \
-v "$PWD/..:/workspace" \
-w /workspace/backend \
"${RUST_IMAGE}" \
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
uses: ./.github/actions/build-binary
with:
rust: ${{ needs.release-info.outputs.rust }}
target: ${{ matrix.target }}
platform: ${{ matrix.os }}-${{ matrix.arch }}
binary: ${{ matrix.binary }}
rust-image: ${{ needs.release-info.outputs.rust_image }}
env:
RUST_IMAGE: ${{ needs.release-info.outputs.rust_image }}
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
@@ -148,7 +195,7 @@ jobs:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
if-no-files-found: error
retention-days: 1
retention-days: 7
build-binary:
name: build-binary (${{ matrix.os }}-${{ matrix.arch }})
@@ -165,61 +212,26 @@ jobs:
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- name: Configure Windows runtime linkage
if: runner.os == 'Windows'
shell: bash
# 发行包静态链接 MSVC 运行库,解压运行时无需另外安装 VC++ Redistributable
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
run: |
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --help
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --version
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
uses: ./.github/actions/build-binary
with:
rust: ${{ needs.release-info.outputs.rust }}
target: ${{ matrix.target }}
platform: ${{ matrix.os }}-${{ matrix.arch }}
binary: ${{ matrix.binary }}
env:
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
- name: Verify Windows platform behavior
if: runner.os == 'Windows'
shell: bash
working-directory: backend
env:
RUST_MIN_STACK: '16777216'
run: |
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-admin --test main use_case::plugins::official::
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-host --test main system_update::
- name: Upload binary artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
if-no-files-found: error
retention-days: 1
retention-days: 7
package-assets:
needs: [release-info, frontend, build-docker-binary, build-binary]
@@ -273,7 +285,7 @@ jobs:
name: release-assets
path: dist/release
if-no-files-found: error
retention-days: 1
retention-days: 7
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
compression-level: 0
@@ -385,14 +397,14 @@ jobs:
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
path: dist/digests/*.txt
if-no-files-found: error
retention-days: 1
retention-days: 7
publish:
concurrency:
group: release-publication
queue: max
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
needs: [release-info, quality, windows, security, package-assets, build-docker-image]
runs-on: ubuntu-26.04
timeout-minutes: 20
permissions:
+9 -3
View File
@@ -1,6 +1,12 @@
name: Security
on:
workflow_call:
inputs:
ref:
description: Exact commit SHA to audit
required: true
type: string
push:
branches:
- main
@@ -14,7 +20,7 @@ permissions:
contents: read
concurrency:
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
group: security-${{ github.workflow }}-${{ inputs.ref || github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
@@ -25,7 +31,7 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Install cargo-audit
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
@@ -44,7 +50,7 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
+4
View File
@@ -232,6 +232,10 @@ git diff --check
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
常规容器检查与发行构建共用二进制构建入口,并复用已检查的前端产物;Dockerfile、工具链、依赖或构建配置变化时执行完整源码镜像构建,定期安全检查也覆盖源码构建。两条路径均验证容器运行、安全扫描与关闭行为
发行构建与质量检查并行,Windows 两种架构的插件与系统更新测试独立运行。仅当发行提交只修改版本与发布说明,且其父提交有本仓库主分支成功的后端 CI 时复用该结果;缺少证据则完整重跑。对外发布等待质量、Windows 测试、依赖审计和镜像验证全部成功,构建耗时与测试报告保留 7 天
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
+34 -8
View File
@@ -13,6 +13,7 @@ usage() {
cat <<USAGE
Usage:
release/publish <version>
release/publish --check <version>
Example:
release/publish 0.1.4
@@ -20,6 +21,7 @@ Example:
Before publishing, replace release/notes.md with the new version's notes,
starting with "# v<version>". Leave the notes uncommitted; this command commits
them together with release/version.yaml. Commit other tracked changes first.
Use --check to validate without changing files, Git refs, or remote state.
USAGE
}
@@ -67,6 +69,14 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
exit 0
fi
check_only=false
if [[ "${1:-}" == "--check" ]]; then
check_only=true
# 只读检查也禁止 Git 的可选索引刷新
export GIT_OPTIONAL_LOCKS=0
shift
fi
[[ "$#" -eq 1 ]] || {
usage >&2
exit 2
@@ -83,6 +93,8 @@ version="$1"
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository"
cd "$repo_root"
version="$(ruby .github/scripts/release-metadata.rb validate "$version")"
ruby .github/scripts/toolchains.rb >/dev/null
ruby -r './.github/scripts/release-metadata' -e 'ReleaseMetadata.platforms'
git ls-files --error-unmatch "$VERSION_FILE" >/dev/null 2>&1 || die "$VERSION_FILE is not tracked"
@@ -117,12 +129,20 @@ if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
die "local tag already exists: $tag"
fi
git fetch "$remote"
if [[ "$check_only" == false ]]; then
git fetch "$remote"
fi
upstream="$remote/$remote_branch"
local_head="$(git rev-parse HEAD)"
upstream_head="$(git rev-parse "$upstream")"
merge_base="$(git merge-base HEAD "$upstream")"
remote_refs="$(git ls-remote --refs "$remote" "$merge_ref" "refs/tags/$tag")" || die "failed to read remote refs"
upstream_head="$(awk -v ref="$merge_ref" '$2 == ref { print $1 }' <<< "$remote_refs")"
[[ -n "$upstream_head" ]] || die "upstream branch does not exist: $upstream"
if awk -v ref="refs/tags/$tag" '$2 == ref { found = 1 } END { exit !found }' <<< "$remote_refs"; then
die "remote tag already exists: $tag"
fi
git cat-file -e "$upstream_head^{commit}" 2>/dev/null || die "upstream has unseen commits; run git fetch $remote first"
merge_base="$(git merge-base HEAD "$upstream_head")" || die "branch has no common history with $upstream"
if [[ "$local_head" != "$upstream_head" && "$merge_base" != "$upstream_head" ]]; then
if [[ "$merge_base" == "$local_head" ]]; then
@@ -131,8 +151,15 @@ if [[ "$local_head" != "$upstream_head" && "$merge_base" != "$upstream_head" ]];
die "branch has diverged from $upstream"
fi
if git ls-remote --exit-code --tags "$remote" "refs/tags/$tag" >/dev/null 2>&1; then
die "remote tag already exists: $tag"
if [[ "$check_only" == true ]]; then
# 预检调用与正式更新相同的纯函数,不写回兼容窗口
ruby -r './backend/crates/gateway-plugin/runtime/plugin-api-lifecycle' -e '
deprecations, compatibility, version = ARGV
PluginApiLifecycle.advance(JSON.parse(File.read(deprecations)), JSON.parse(File.read(compatibility)), version)
' "$PLUGIN_DEPRECATIONS_FILE" "$PLUGIN_COMPATIBILITY_FILE" "$version"
echo "Preflight passed: $APP_NAME $version on $branch -> $repository ($remote_branch)"
echo "No files, Git refs, or remote state changed. CI and release artifact validation still run after dispatch."
exit 0
fi
ruby backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb "$PLUGIN_DEPRECATIONS_FILE" "$PLUGIN_COMPATIBILITY_FILE" "$version"
@@ -146,8 +173,7 @@ git commit \
git tag -a "$tag" -m "$tag"
git push --atomic "$remote" "HEAD:$remote_branch" "refs/tags/$tag"
# Dispatch from the long-lived release branch instead of the tag so GitHub
# Actions caches remain reusable across releases.
# 从长期发行分支触发,使 Actions 缓存可跨版本复用
if ! gh workflow run release.yml \
--repo "$repository" \
--ref "$remote_branch" \
@@ -158,7 +184,7 @@ if ! gh workflow run release.yml \
fi
cat <<DONE
Published $APP_NAME $version
Queued release for $APP_NAME $version
Commit: $(git rev-parse --short HEAD)
Tag: $tag
Release workflow dispatched from: $remote_branch