diff --git a/.agents/skills/cpr-release/SKILL.md b/.agents/skills/cpr-release/SKILL.md index 39c68956e..56b77cfc6 100644 --- a/.agents/skills/cpr-release/SKILL.md +++ b/.agents/skills/cpr-release/SKILL.md @@ -18,7 +18,7 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行 ## 必须保持的边界 - Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替 -- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练 +- `release/publish --check ` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权 - 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突 - 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对 - `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md) diff --git a/.agents/skills/cpr-release/references/operations.md b/.agents/skills/cpr-release/references/operations.md index 74b14d2d8..7bed63ace 100644 --- a/.agents/skills/cpr-release/references/operations.md +++ b/.agents/skills/cpr-release/references/operations.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/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功; diff --git a/.agents/skills/cpr-release/references/preparation.md b/.agents/skills/cpr-release/references/preparation.md index 7a4155d44..910c7f2ba 100644 --- a/.agents/skills/cpr-release/references/preparation.md +++ b/.agents/skills/cpr-release/references/preparation.md @@ -26,6 +26,9 @@ 进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查 +发布说明准备好、其他代码已提交后,执行 `release/publish --check `,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。 +预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权 + 按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行 数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则): diff --git a/.github/actions/build-binary/action.yml b/.github/actions/build-binary/action.yml new file mode 100644 index 000000000..a2d171a77 --- /dev/null +++ b/.github/actions/build-binary/action.yml @@ -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 diff --git a/.github/actions/build-frontend/action.yml b/.github/actions/build-frontend/action.yml index f5a1a82d5..9e661a04b 100644 --- a/.github/actions/build-frontend/action.yml +++ b/.github/actions/build-frontend/action.yml @@ -43,4 +43,4 @@ runs: name: frontend-dist path: frontend/dist if-no-files-found: error - retention-days: 1 + retention-days: 7 diff --git a/.github/actions/cargo-cache/Dockerfile b/.github/actions/cargo-cache/Dockerfile deleted file mode 100644 index 0b4d14967..000000000 --- a/.github/actions/cargo-cache/Dockerfile +++ /dev/null @@ -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 . diff --git a/.github/actions/cargo-cache/action.yml b/.github/actions/cargo-cache/action.yml deleted file mode 100644 index a7e1bad06..000000000 --- a/.github/actions/cargo-cache/action.yml +++ /dev/null @@ -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" diff --git a/.github/actions/cargo-cache/cargo-cache.sh b/.github/actions/cargo-cache/cargo-cache.sh deleted file mode 100644 index ff703e181..000000000 --- a/.github/actions/cargo-cache/cargo-cache.sh +++ /dev/null @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bc0a0b6ab..ad755d36a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -70,7 +70,6 @@ updates: - package-ecosystem: docker directories: - /deploy - - /.github/actions/cargo-cache labels: [] schedule: interval: weekly diff --git a/.github/scripts/release-checks-test.rb b/.github/scripts/release-checks-test.rb new file mode 100644 index 000000000..5c931b7bc --- /dev/null +++ b/.github/scripts/release-checks-test.rb @@ -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' diff --git a/.github/scripts/release-metadata.rb b/.github/scripts/release-metadata.rb index 289921b79..c1e066b8c 100644 --- a/.github/scripts/release-metadata.rb +++ b/.github/scripts/release-metadata.rb @@ -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), diff --git a/.github/scripts/release-quality.rb b/.github/scripts/release-quality.rb new file mode 100644 index 000000000..2d566867e --- /dev/null +++ b/.github/scripts/release-quality.rb @@ -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 diff --git a/.github/workflows/_container.yml b/.github/workflows/_container.yml index e41bb61f8..05f718e0e 100644 --- a/.github/workflows/_container.yml +++ b/.github/workflows/_container.yml @@ -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 }} diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index 688648405..cc2cab5f1 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 471f5a22c..cfbe4b63d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b66aacffe..0ba7ba6d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b1ec06a24..94119a0b5 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 145bb8ddb..d73d27194 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) diff --git a/release/publish b/release/publish index 90d700dd2..ac6b592f0 100755 --- a/release/publish +++ b/release/publish @@ -13,6 +13,7 @@ usage() { cat < + release/publish --check 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". 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 <