mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
Use the current host manifest format and require restart confirmation on all management UI paths. Document v3.18.1 as the minimum online upgrade source while retaining old release manifest reads for supported rollbacks. Retire middleware v3 and upstream_adapter v1 Fast projections after their compatibility window. Align SDK and host declarations with the current contracts, keep risk warnings and per-instance failure isolation, and exercise native fast_mode values through real plugin subprocesses. Keep deployment, API and SDK documentation focused on current behavior. Co-authored-by: Codex <noreply@openai.com>
828 lines
33 KiB
YAML
828 lines
33 KiB
YAML
name: Release
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: 'Tag to release, for example v0.1.0'
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: release-${{ inputs.tag }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
REGISTRY: ghcr.io
|
|
IMAGE_NAME: zyycn/codex-proxy-rs
|
|
|
|
jobs:
|
|
release-info:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
outputs:
|
|
tag: ${{ steps.version.outputs.tag }}
|
|
version: ${{ steps.version.outputs.version }}
|
|
docker_version: ${{ steps.version.outputs.docker_version }}
|
|
build_time: ${{ steps.version.outputs.build_time }}
|
|
git_sha: ${{ steps.version.outputs.git_sha }}
|
|
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
|
|
docker_matrix: ${{ steps.version.outputs.docker_matrix }}
|
|
non_docker_matrix: ${{ steps.version.outputs.non_docker_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 }}
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ inputs.tag }}
|
|
fetch-depth: 0
|
|
|
|
- name: Read release metadata
|
|
id: version
|
|
shell: bash
|
|
env:
|
|
RELEASE_TAG: ${{ inputs.tag }}
|
|
run: |
|
|
ruby <<'RUBY' >> "$GITHUB_OUTPUT"
|
|
require "json"
|
|
require "open3"
|
|
require "rubygems"
|
|
require "time"
|
|
require "yaml"
|
|
|
|
APP_NAME = "codex-proxy-rs"
|
|
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
|
|
|
|
def fail(message)
|
|
warn(message)
|
|
exit(1)
|
|
end
|
|
|
|
def require_string(data, key, path)
|
|
value = data[key]
|
|
fail("#{path}: #{key} must be a non-empty string") unless value.is_a?(String) && !value.strip.empty?
|
|
value.strip
|
|
end
|
|
|
|
def version_sort_key(version)
|
|
Gem::Version.new(version.split("+", 2).first)
|
|
rescue ArgumentError
|
|
nil
|
|
end
|
|
|
|
version_path = "release/version.yaml"
|
|
platforms_path = "release/platforms.yaml"
|
|
version_data = YAML.load_file(version_path)
|
|
platforms_data = YAML.load_file(platforms_path)
|
|
|
|
version = require_string(version_data, "version", version_path).delete_prefix("v")
|
|
tag_prefix = "v"
|
|
expected_tag = "#{tag_prefix}#{version}"
|
|
release_tag = ENV.fetch("RELEASE_TAG")
|
|
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
|
|
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
|
|
|
|
notes_path = "release/notes.md"
|
|
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
|
|
notes = File.readlines(notes_path)
|
|
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
|
|
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
|
|
end
|
|
|
|
platforms = platforms_data["platforms"]
|
|
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
|
|
|
|
required_platform_keys = %w[os arch runner target binary archive]
|
|
seen_platforms = {}
|
|
docker_platform_jobs = []
|
|
non_docker_platform_jobs = []
|
|
docker_platforms = []
|
|
platforms.each_with_index do |platform, index|
|
|
fail("#{platforms_path}: platforms[#{index}] must be a table") unless platform.is_a?(Hash)
|
|
required_platform_keys.each do |key|
|
|
require_string(platform, key, "#{platforms_path}: platforms[#{index}]")
|
|
end
|
|
archive = platform["archive"]
|
|
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz") unless archive == "tar.gz"
|
|
platform_key = "#{platform["os"]}-#{platform["arch"]}"
|
|
fail("#{platforms_path}: duplicate platform #{platform_key}") if seen_platforms[platform_key]
|
|
seen_platforms[platform_key] = true
|
|
platform["artifact"] = "#{APP_NAME}-#{platform_key}"
|
|
if platform["docker"] == true
|
|
docker_platform = require_string(platform, "dockerPlatform", "#{platforms_path}: platforms[#{index}]")
|
|
platform["dockerPlatform"] = docker_platform
|
|
docker_platform_jobs << platform
|
|
docker_platforms << docker_platform
|
|
else
|
|
non_docker_platform_jobs << platform
|
|
end
|
|
end
|
|
|
|
fail("#{platforms_path}: at least one platform must set docker = true") if docker_platforms.empty?
|
|
|
|
git_sha, status = Open3.capture2("git", "rev-parse", "HEAD")
|
|
fail("failed to resolve git sha") unless status.success?
|
|
matrix = JSON.generate({ "include" => platforms })
|
|
docker_matrix = JSON.generate({ "include" => docker_platform_jobs })
|
|
non_docker_matrix = JSON.generate({ "include" => non_docker_platform_jobs })
|
|
current_sort_key = version_sort_key(version)
|
|
tags, status = Open3.capture2("git", "tag", "--list", "#{tag_prefix}*", "--merged", "HEAD")
|
|
fail("failed to list git tags") unless status.success?
|
|
previous_tag = tags
|
|
.lines
|
|
.map(&:strip)
|
|
.reject(&:empty?)
|
|
.reject { |tag| tag == release_tag }
|
|
.filter_map do |tag|
|
|
tag_version = tag.delete_prefix(tag_prefix)
|
|
next unless tag_version.match?(SEMVER)
|
|
sort_key = version_sort_key(tag_version)
|
|
next unless sort_key && current_sort_key && sort_key < current_sort_key
|
|
[sort_key, tag]
|
|
end
|
|
.max_by(&:first)
|
|
&.last
|
|
|
|
puts "tag=#{release_tag}"
|
|
puts "version=#{version}"
|
|
# Docker tags forbid "+", so semver build metadata maps to "-" in image tags.
|
|
puts "docker_version=#{version.tr("+", "-")}"
|
|
puts "build_time=#{Time.now.utc.iso8601}"
|
|
puts "git_sha=#{git_sha.strip}"
|
|
puts "platform_matrix=#{matrix}"
|
|
puts "docker_matrix=#{docker_matrix}"
|
|
puts "non_docker_matrix=#{non_docker_matrix}"
|
|
puts "docker_platforms=#{docker_platforms.join(",")}"
|
|
puts "previous_tag=#{previous_tag}"
|
|
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
|
|
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
|
|
RUBY
|
|
|
|
- name: Seal plugin release manifest
|
|
env:
|
|
VERSION: ${{ steps.version.outputs.version }}
|
|
GIT_SHA: ${{ steps.version.outputs.git_sha }}
|
|
run: |
|
|
ruby <<'RUBY'
|
|
require "fileutils"
|
|
require "json"
|
|
|
|
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
|
|
host = JSON.parse(File.read(host_path))
|
|
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
|
|
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
|
|
manifest = {
|
|
"schema_version" => 1,
|
|
"sealed" => true,
|
|
"gateway_version" => ENV.fetch("VERSION"),
|
|
"gateway_git_sha" => ENV.fetch("GIT_SHA"),
|
|
"plugin_host" => host,
|
|
"plugins" => [],
|
|
}
|
|
FileUtils.mkdir_p("dist/plugin-release-assets")
|
|
File.write("dist/plugin-release-assets/plugin-release-manifest.json", JSON.pretty_generate(manifest) + "\n")
|
|
RUBY
|
|
|
|
- name: Upload sealed plugin assets
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: plugin-release-assets
|
|
path: dist/plugin-release-assets
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
quality:
|
|
needs: [release-info]
|
|
uses: ./.github/workflows/_quality.yml
|
|
with:
|
|
ref: ${{ needs.release-info.outputs.git_sha }}
|
|
run_frontend: false
|
|
|
|
frontend:
|
|
needs: release-info
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
env:
|
|
CI: 'true'
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.release-info.outputs.git_sha }}
|
|
|
|
- name: Validate and build frontend
|
|
uses: ./.github/actions/build-frontend
|
|
with:
|
|
upload-artifact: 'true'
|
|
|
|
build-docker-binary:
|
|
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
|
|
needs: release-info
|
|
strategy:
|
|
fail-fast: false
|
|
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
|
|
runs-on: ${{ matrix.runner }}
|
|
timeout-minutes: 90
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
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 }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
|
|
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:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa \
|
|
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
|
|
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: Upload binary artifact
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
|
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
build-binary:
|
|
name: build-binary (${{ matrix.os }}-${{ matrix.arch }})
|
|
needs: release-info
|
|
strategy:
|
|
fail-fast: false
|
|
matrix: ${{ fromJson(needs.release-info.outputs.non_docker_matrix) }}
|
|
runs-on: ${{ matrix.runner }}
|
|
timeout-minutes: 90
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.release-info.outputs.git_sha }}
|
|
|
|
- name: Setup Rust
|
|
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
|
with:
|
|
targets: ${{ matrix.target }}
|
|
|
|
- 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', '.github/workflows/release.yml') }}
|
|
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
|
|
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
|
|
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: Upload binary artifact
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
|
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
package-assets:
|
|
needs: [release-info, frontend, build-docker-binary, build-binary]
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
permissions:
|
|
contents: read
|
|
id-token: write
|
|
attestations: write
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.release-info.outputs.git_sha }}
|
|
|
|
- name: Download frontend artifact
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: frontend-dist
|
|
path: dist/frontend
|
|
|
|
- name: Download binary artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: codex-proxy-rs-*
|
|
path: dist/binaries
|
|
|
|
- name: Download sealed plugin assets
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: plugin-release-assets
|
|
path: dist/plugin-release-assets
|
|
|
|
- name: Package release assets
|
|
shell: bash
|
|
env:
|
|
VERSION: ${{ needs.release-info.outputs.version }}
|
|
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
|
run: |
|
|
set -euo pipefail
|
|
ruby <<'RUBY' > release-platforms.json
|
|
require "json"
|
|
require "yaml"
|
|
|
|
print JSON.generate(YAML.load_file("release/platforms.yaml"))
|
|
RUBY
|
|
python3 - <<'PY'
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import tarfile
|
|
|
|
APP_NAME = "codex-proxy-rs"
|
|
|
|
def sha256(path: pathlib.Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as file:
|
|
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
version = os.environ["VERSION"]
|
|
platforms = json.loads(pathlib.Path("release-platforms.json").read_text())["platforms"]
|
|
frontend_dir = pathlib.Path("dist/frontend")
|
|
binary_artifacts_dir = pathlib.Path("dist/binaries")
|
|
release_dir = pathlib.Path("dist/release")
|
|
package_dir = pathlib.Path("package")
|
|
|
|
shutil.rmtree(release_dir, ignore_errors=True)
|
|
release_dir.mkdir(parents=True)
|
|
config_template = pathlib.Path("deploy/config.example.yaml")
|
|
shutil.copy2(config_template, release_dir / config_template.name)
|
|
compose = pathlib.Path("deploy/compose.yaml").read_text()
|
|
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
|
|
default_image = "${CPR_IMAGE:-" + image + ":latest}"
|
|
if compose.count(default_image) != 1:
|
|
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
|
|
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
|
|
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
|
|
compose = compose.replace(default_image, versioned_image)
|
|
(release_dir / "compose.yaml").write_text(compose)
|
|
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
|
|
shutil.rmtree(package_dir, ignore_errors=True)
|
|
release_packages = []
|
|
|
|
for platform in platforms:
|
|
platform_name = f"{platform['os']}-{platform['arch']}"
|
|
artifact_name = f"{APP_NAME}-{platform_name}"
|
|
binary_src = binary_artifacts_dir / artifact_name / platform["binary"]
|
|
if not binary_src.is_file():
|
|
raise SystemExit(f"Missing binary artifact: {binary_src}")
|
|
|
|
package_root = package_dir / platform_name / APP_NAME
|
|
web_dist = package_root / "web" / "dist"
|
|
web_dist.mkdir(parents=True)
|
|
|
|
binary_dest = package_root / platform["binary"]
|
|
shutil.copy2(binary_src, binary_dest)
|
|
binary_dest.chmod(0o755)
|
|
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
|
|
deploy_dir = package_root / "deploy"
|
|
deploy_dir.mkdir()
|
|
shutil.copy2(config_template, deploy_dir / config_template.name)
|
|
|
|
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
|
archive_path = release_dir / archive_name
|
|
if platform["archive"] != "tar.gz":
|
|
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
|
release_packages.append((platform, package_root, archive_path))
|
|
|
|
plugin_manifest_path = release_dir / "plugin-release-manifest.json"
|
|
shutil.copy2("dist/plugin-release-assets/plugin-release-manifest.json", plugin_manifest_path)
|
|
assets.append(plugin_manifest_path)
|
|
|
|
# 保留运行时清单目录合同,宿主包不再携带教学示例。
|
|
for platform, package_root, archive_path in release_packages:
|
|
official_dir = package_root / "plugins" / "official"
|
|
official_dir.mkdir(parents=True)
|
|
bundled_manifest = official_dir / plugin_manifest_path.name
|
|
shutil.copy2(plugin_manifest_path, bundled_manifest)
|
|
bundled_manifest.chmod(0o444)
|
|
official_dir.chmod(0o555)
|
|
with tarfile.open(archive_path, "w:gz") as archive:
|
|
archive.add(package_root, arcname=APP_NAME)
|
|
assets.append(archive_path)
|
|
|
|
checksum_path = release_dir / "checksums.txt"
|
|
with checksum_path.open("w", encoding="utf-8") as file:
|
|
for asset in assets:
|
|
file.write(f"{sha256(asset)} {asset.name}\n")
|
|
PY
|
|
|
|
- name: Generate release asset provenance
|
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
|
with:
|
|
subject-path: dist/release/*
|
|
|
|
- name: Upload release assets
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: release-assets
|
|
path: dist/release
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
|
|
compression-level: 0
|
|
|
|
build-docker-image:
|
|
name: build-docker-image (${{ matrix.dockerPlatform }})
|
|
needs: [release-info, frontend, build-docker-binary]
|
|
strategy:
|
|
fail-fast: false
|
|
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
|
|
runs-on: ${{ matrix.runner }}
|
|
timeout-minutes: 45
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
id-token: write
|
|
security-events: write
|
|
env:
|
|
TRIVY_QUIET: 'true'
|
|
TRIVY_SKIP_VERSION_CHECK: 'true'
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.release-info.outputs.git_sha }}
|
|
|
|
- name: Download frontend artifact
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: frontend-dist
|
|
path: dist/docker/web/dist
|
|
|
|
- name: Download Docker binary artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
|
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
|
|
|
- name: Download sealed plugin assets
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: plugin-release-assets
|
|
path: dist/plugin-release-assets
|
|
|
|
- name: Prepare Docker prebuilt assets
|
|
shell: bash
|
|
env:
|
|
MATRIX_OS: ${{ matrix.os }}
|
|
MATRIX_ARCH: ${{ matrix.arch }}
|
|
MATRIX_BINARY: ${{ matrix.binary }}
|
|
run: |
|
|
set -euo pipefail
|
|
python3 - <<'PY'
|
|
import json
|
|
import shutil
|
|
import os
|
|
import pathlib
|
|
|
|
app_name = "codex-proxy-rs"
|
|
platform_name = f"{os.environ['MATRIX_OS']}-{os.environ['MATRIX_ARCH']}"
|
|
binary_src = pathlib.Path("dist/docker-artifacts") / f"{app_name}-{platform_name}" / os.environ["MATRIX_BINARY"]
|
|
if not binary_src.is_file():
|
|
raise SystemExit(f"Missing Docker binary artifact: {binary_src}")
|
|
|
|
target_dir = pathlib.Path("dist/docker") / platform_name
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
binary_dest = target_dir / app_name
|
|
shutil.copy2(binary_src, binary_dest)
|
|
binary_dest.chmod(0o755)
|
|
|
|
release_dir = pathlib.Path("dist/plugin-release-assets")
|
|
manifest_path = release_dir / "plugin-release-manifest.json"
|
|
manifest = json.loads(manifest_path.read_text())
|
|
if manifest.get("schema_version") != 1 or manifest.get("sealed") is not True:
|
|
raise SystemExit("Invalid sealed plugin release manifest")
|
|
official_dir = target_dir / "plugins" / "official"
|
|
official_dir.mkdir(parents=True)
|
|
bundled_manifest = official_dir / manifest_path.name
|
|
shutil.copy2(manifest_path, bundled_manifest)
|
|
bundled_manifest.chmod(0o444)
|
|
selected = 0
|
|
for plugin in manifest.get("plugins", []):
|
|
artifacts = [
|
|
artifact for artifact in plugin.get("artifacts", [])
|
|
if artifact.get("release_os") == os.environ["MATRIX_OS"]
|
|
and artifact.get("release_architecture") == os.environ["MATRIX_ARCH"]
|
|
]
|
|
if len(artifacts) != 1:
|
|
raise SystemExit(f"Official plugin platform selection is not unique: {plugin.get('id')}")
|
|
archive = release_dir / artifacts[0]["archive"]
|
|
if not archive.is_file():
|
|
raise SystemExit(f"Missing official plugin archive: {archive.name}")
|
|
bundled_archive = official_dir / archive.name
|
|
shutil.copy2(archive, bundled_archive)
|
|
bundled_archive.chmod(0o444)
|
|
selected += 1
|
|
if selected != len(manifest.get("plugins", [])):
|
|
raise SystemExit("Official plugin release bundle is incomplete")
|
|
official_dir.chmod(0o555)
|
|
PY
|
|
|
|
- name: Setup Docker Buildx
|
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
|
|
|
- name: Login to GHCR
|
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Build and push app image
|
|
id: build-and-push
|
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
|
with:
|
|
context: .
|
|
file: deploy/Dockerfile
|
|
target: runtime-prebuilt
|
|
platforms: ${{ matrix.dockerPlatform }}
|
|
push: true
|
|
build-args: |
|
|
CPR_PREBUILT_DIR=dist/docker
|
|
no-cache-filters: |
|
|
runtime-base
|
|
runtime-prebuilt
|
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
|
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
|
|
provenance: mode=max
|
|
sbom: true
|
|
labels: |
|
|
org.opencontainers.image.title=Codex Proxy RS
|
|
org.opencontainers.image.version=${{ needs.release-info.outputs.version }}
|
|
org.opencontainers.image.revision=${{ needs.release-info.outputs.git_sha }}
|
|
org.opencontainers.image.source=https://github.com/zyycn/codex-proxy-rs
|
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }}-${{ matrix.arch }}
|
|
|
|
- name: Verify exact app image digest
|
|
uses: ./.github/actions/verify-container
|
|
with:
|
|
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
|
platform: ${{ matrix.dockerPlatform }}
|
|
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }}
|
|
|
|
- name: Record verified image digest
|
|
shell: bash
|
|
env:
|
|
IMAGE_DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
|
IMAGE_ARCH: ${{ matrix.arch }}
|
|
run: |
|
|
set -euo pipefail
|
|
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
|
mkdir -p dist/digests
|
|
printf '%s\n' "$IMAGE_DIGEST" > "dist/digests/linux-${IMAGE_ARCH}.txt"
|
|
|
|
- name: Upload verified image digest
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
|
|
path: dist/digests/*.txt
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
publish:
|
|
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
|
|
needs: [release-info, quality, package-assets, build-docker-image]
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
permissions:
|
|
contents: write
|
|
packages: write
|
|
id-token: write
|
|
attestations: write
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.release-info.outputs.git_sha }}
|
|
|
|
- name: Download release assets
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: release-assets
|
|
path: dist/release
|
|
|
|
- name: Download verified image digests
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: image-digest-*
|
|
path: dist/digests
|
|
merge-multiple: true
|
|
|
|
- name: Setup Docker Buildx
|
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
|
|
|
- name: Install Cosign
|
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
|
|
|
- name: Login to GHCR
|
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Create app image manifest
|
|
id: manifest
|
|
shell: bash
|
|
env:
|
|
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
|
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
|
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
|
|
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
image="${REGISTRY}/${IMAGE_NAME}"
|
|
sources=()
|
|
IFS=',' read -ra platforms <<< "${DOCKER_PLATFORMS}"
|
|
for platform in "${platforms[@]}"; do
|
|
platform="${platform//[[:space:]]/}"
|
|
[ -n "${platform}" ] || continue
|
|
arch="${platform##*/}"
|
|
digest="$(cat "dist/digests/linux-${arch}.txt")"
|
|
if [[ ! "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]; then
|
|
echo "invalid verified image digest for ${platform}" >&2
|
|
exit 1
|
|
fi
|
|
sources+=("${image}@${digest}")
|
|
done
|
|
|
|
if [ "${#sources[@]}" -eq 0 ]; then
|
|
echo "missing Docker image sources"
|
|
exit 1
|
|
fi
|
|
|
|
target_args=(
|
|
-t "${image}:${DOCKER_VERSION}"
|
|
-t "${image}:sha-${GIT_SHA}"
|
|
)
|
|
if [ "$PRERELEASE" != true ]; then
|
|
target_args+=(-t "${image}:latest")
|
|
fi
|
|
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
|
|
|
|
inspect_output="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}")"
|
|
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
|
|
for platform in "${platforms[@]}"; do
|
|
if ! grep -Eq "^${platform}(/|$)" <<< "${platform_list}"; then
|
|
echo "image manifest is missing platform ${platform}"
|
|
echo "${inspect_output}"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
digest="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}" --format '{{ .Manifest.Digest }}')"
|
|
if [ -z "${digest}" ]; then
|
|
echo "missing image digest"
|
|
exit 1
|
|
fi
|
|
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Generate container provenance attestation
|
|
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
|
with:
|
|
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
subject-digest: ${{ steps.manifest.outputs.digest }}
|
|
push-to-registry: true
|
|
|
|
- name: Sign app image
|
|
shell: bash
|
|
env:
|
|
DIGEST: ${{ steps.manifest.outputs.digest }}
|
|
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
|
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
|
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${DIGEST}" ]; then
|
|
echo "missing image digest"
|
|
exit 1
|
|
fi
|
|
|
|
image="${REGISTRY}/${IMAGE_NAME}"
|
|
tags=("${image}:${DOCKER_VERSION}" "${image}:sha-${GIT_SHA}")
|
|
if [ "$PRERELEASE" != true ]; then
|
|
tags+=("${image}:latest")
|
|
fi
|
|
images=()
|
|
for tag in "${tags[@]}"; do
|
|
images+=("${tag}@${DIGEST}")
|
|
done
|
|
|
|
cosign sign --yes "${images[@]}"
|
|
|
|
- name: Prepare release notes
|
|
shell: bash
|
|
env:
|
|
INSTALLATION_NOTES: |
|
|
## 📥 安装
|
|
|
|
**Docker 部署:**
|
|
```bash
|
|
# 从 GitHub 容器镜像仓库拉取
|
|
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
|
```
|
|
|
|
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
|
|
|
|
**手动下载:**
|
|
请从下方附件中下载适合当前平台的归档包。
|
|
|
|
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
|
|
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
|
|
|
|
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
|
|
|
## 📚 文档
|
|
|
|
- [GitHub 仓库](https://github.com/${{ github.repository }})
|
|
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
|
|
run: |
|
|
python3 <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
|
|
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
|
|
installation = os.environ["INSTALLATION_NOTES"].strip()
|
|
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
|
|
output.write_text(f"{notes}\n\n{installation}\n")
|
|
PY
|
|
|
|
- name: Publish GitHub Release
|
|
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
|
with:
|
|
tag_name: ${{ needs.release-info.outputs.tag }}
|
|
generate_release_notes: true
|
|
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
|
|
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
|
|
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
|
|
body_path: ${{ runner.temp }}/release-notes.md
|
|
fail_on_unmatched_files: true
|
|
files: |
|
|
dist/release/*
|