name: Release on: push: tags: - 'v*' workflow_dispatch: inputs: tag: description: 'Tag to release, for example v0.1.0' required: true type: string permissions: contents: read concurrency: group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} 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 }} 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 }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} fetch-depth: 0 - name: Read release metadata id: version shell: bash env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} 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*)(?:-[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) 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 or zip") unless ["tar.gz", "zip"].include?(archive) 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}" 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?("-")}" RUBY quality: needs: [release-info] uses: ./.github/workflows/_quality.yml with: ref: ${{ needs.release-info.outputs.git_sha }} upload_frontend: 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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 key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock') }} restore-keys: | cargo-${{ 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: release - 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock') }} restore-keys: | cargo-${{ 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: release - 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, quality, 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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: Package release assets shell: bash env: VERSION: ${{ needs.release-info.outputs.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 import zipfile 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) archives = [] 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}") shutil.rmtree(package_dir, ignore_errors=True) package_root = package_dir / 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) if platform["os"] != "windows": binary_dest.chmod(0o755) shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True) archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}" archive_path = release_dir / archive_name if platform["archive"] == "tar.gz": with tarfile.open(archive_path, "w:gz") as archive: archive.add(package_root, arcname=APP_NAME) elif platform["archive"] == "zip": with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive: for path in sorted(package_root.rglob("*")): if path.is_file(): archive.write(path, path.relative_to(package_dir)) else: raise SystemExit(f"Unsupported archive format: {platform['archive']}") archives.append(archive_path) checksum_path = release_dir / "checksums.txt" with checksum_path.open("w", encoding="utf-8") as file: for archive in archives: file.write(f"{sha256(archive)} {archive.name}\n") PY - name: Generate release asset provenance uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 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 build-docker-image: name: build-docker-image (${{ matrix.dockerPlatform }}) needs: [release-info, quality, 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 env: TRIVY_QUIET: 'true' TRIVY_SKIP_VERSION_CHECK: 'true' steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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: 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 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) PY - name: Setup Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: deploy/Dockerfile target: runtime-prebuilt platforms: ${{ matrix.dockerPlatform }} push: true build-args: | CPR_PREBUILT_DIR=dist/docker cache-from: type=gha,scope=runtime-prebuilt-${{ matrix.os }}-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=runtime-prebuilt-${{ matrix.os }}-${{ matrix.arch }} 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: Scan exact app image digest uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 env: TRIVY_PLATFORM: ${{ matrix.dockerPlatform }} with: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} version: v0.72.0 format: table severity: HIGH,CRITICAL vuln-type: os,library scanners: vuln ignore-unfixed: true exit-code: '1' publish: needs: [release-info, 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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: Setup Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Login to GHCR uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Create app image manifest id: manifest shell: bash env: VERSION: ${{ needs.release-info.outputs.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##*/}" sources+=("${image}:sha-${GIT_SHA}-${arch}") done if [ "${#sources[@]}" -eq 0 ]; then echo "missing Docker image sources" exit 1 fi target_args=( -t "${image}:${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}:${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}:${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@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 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 }} VERSION: ${{ needs.release-info.outputs.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}:${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: Publish GitHub Release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 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: | ## 📥 Installation **Docker:** ```bash # GitHub Container Registry docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }} ``` **Manual download:** Download the appropriate archive for your platform from the assets below. Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures. ## 📚 Documentation - [GitHub Repository](https://github.com/${{ github.repository }}) - [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md) fail_on_unmatched_files: true files: | dist/release/*