From 4dcd9dc31d393c7dd4e86eb719cdc57cde3ef2e1 Mon Sep 17 00:00:00 2001 From: zyycn Date: Sat, 11 Jul 2026 17:18:17 +0800 Subject: [PATCH] feat: harden deployment and CI pipeline Co-authored-by: Codex --- .github/dependabot.yml | 54 ++++++ .github/workflows/_container.yml | 237 ++++++++++++++++++++++++++ .github/workflows/_quality.yml | 137 +++++++++++++++ .github/workflows/ci.yml | 115 ++++--------- .github/workflows/release.yml | 184 ++++++++++---------- .github/workflows/security-scan.yml | 48 +++--- .gitignore | 1 + README.md | 20 ++- backend/Cargo.lock | 2 + backend/Cargo.toml | 2 + backend/src/bootstrap/config.rs | 59 ++++++- backend/tests/bootstrap/config/mod.rs | 163 +++++++++++++++++- deploy/.env.example | 4 + deploy/Dockerfile | 13 +- deploy/README.md | 30 ++-- deploy/config.example.yaml | 2 +- deploy/docker-compose.smoke.yml | 11 ++ deploy/docker-compose.yml | 13 +- docs/deploy-ci-review.md | 35 ++-- 19 files changed, 884 insertions(+), 246 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/_container.yml create mode 100644 .github/workflows/_quality.yml create mode 100644 deploy/.env.example create mode 100644 deploy/docker-compose.smoke.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..e6c7042a1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,54 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: '09:00' + timezone: Asia/Shanghai + groups: + minor-and-patch: + update-types: + - minor + - patch + + - package-ecosystem: cargo + directory: /backend + schedule: + interval: weekly + day: monday + time: '09:10' + timezone: Asia/Shanghai + groups: + minor-and-patch: + update-types: + - minor + - patch + + - package-ecosystem: npm + directory: /frontend + schedule: + interval: weekly + day: monday + time: '09:20' + timezone: Asia/Shanghai + groups: + minor-and-patch: + update-types: + - minor + - patch + + - package-ecosystem: docker + directory: /deploy + schedule: + interval: weekly + day: monday + time: '09:30' + timezone: Asia/Shanghai + groups: + minor-and-patch: + update-types: + - minor + - patch diff --git a/.github/workflows/_container.yml b/.github/workflows/_container.yml new file mode 100644 index 000000000..253c79d81 --- /dev/null +++ b/.github/workflows/_container.yml @@ -0,0 +1,237 @@ +name: Reusable Container + +on: + workflow_call: + inputs: + ref: + description: Exact commit SHA to build and validate + required: true + type: string + +permissions: + contents: read + security-events: write + +jobs: + container: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + COMPOSE_PROJECT_NAME: cpr-ci-${{ github.run_id }}-${{ github.run_attempt }} + CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }} + CPR_ADMIN_DEFAULT_PASSWORD: 'admin @:/?#%2F ci password' + CPR_POSTGRES_PASSWORD: 'pg @:/?#%2F ci' + CPR_REDIS_PASSWORD: 'redis @:/?#%2F ci' + CPR_SMOKE_PORT: '18080' + TRIVY_QUIET: 'true' + TRIVY_SKIP_VERSION_CHECK: 'true' + steps: + - name: Define isolated runtime paths + shell: bash + run: | + { + echo "CPR_CONFIG_FILE=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/config.yaml" + echo "CPR_DATA_DIR=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/data" + echo "CPR_LOG_DIR=$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT/logs" + echo "DIAGNOSTICS_DIR=$RUNNER_TEMP/codex-proxy-rs-diagnostics" + } >> "$GITHUB_ENV" + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.ref }} + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Build runtime image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: deploy/Dockerfile + target: runtime + load: true + tags: ${{ env.CPR_IMAGE }} + cache-from: type=gha,scope=runtime-ci + cache-to: type=gha,mode=max,scope=runtime-ci + + - name: Generate Trivy SARIF + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.CPR_IMAGE }} + version: v0.72.0 + format: sarif + output: trivy-results.sarif + severity: HIGH,CRITICAL + vuln-type: os,library + scanners: vuln + ignore-unfixed: true + exit-code: '0' + + - name: Upload Trivy SARIF + if: >- + ${{ hashFiles('trivy-results.sarif') != '' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) }} + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + with: + sarif_file: trivy-results.sarif + category: runtime-container + + - name: Enforce Trivy vulnerability gate + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.CPR_IMAGE }} + version: v0.72.0 + format: table + severity: HIGH,CRITICAL + vuln-type: os,library + scanners: vuln + ignore-unfixed: true + exit-code: '1' + + - name: Prepare isolated runtime + shell: bash + run: | + set -euo pipefail + runtime_dir="$(dirname "$CPR_CONFIG_FILE")" + mkdir -p "$runtime_dir" "$DIAGNOSTICS_DIR" + sudo install -d -m 0770 -o "$(id -u)" -g 10001 "$CPR_DATA_DIR" "$CPR_LOG_DIR" + cp deploy/config.example.yaml "$CPR_CONFIG_FILE" + sudo chown "$(id -u):10001" "$CPR_CONFIG_FILE" + chmod 0640 "$CPR_CONFIG_FILE" + + - name: Validate Docker Compose + shell: bash + run: | + set -euo pipefail + docker compose --project-name "$COMPOSE_PROJECT_NAME" \ + -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml config --quiet + + - name: Verify PostgreSQL and Redis volume persistence + shell: bash + run: | + set -euo pipefail + compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \ + -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml) + "${compose[@]}" up -d --no-build --wait postgres redis + "${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \ + psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \ + "CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');" + "${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \ + redis-cli SET cpr:ci:persistence-probe persisted + "${compose[@]}" up -d --no-build --wait --force-recreate postgres redis + test "$("${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \ + psql -At -U codex_proxy -d codex_proxy -c \ + "SELECT value FROM cpr_ci_persistence_probe;")" = persisted + test "$("${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \ + redis-cli --raw GET cpr:ci:persistence-probe | tr -d '\r')" = persisted + "${compose[@]}" exec -T -e PGPASSWORD="$CPR_POSTGRES_PASSWORD" postgres \ + psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \ + "DROP TABLE cpr_ci_persistence_probe;" + "${compose[@]}" exec -T -e REDISCLI_AUTH="$CPR_REDIS_PASSWORD" redis \ + redis-cli DEL cpr:ci:persistence-probe + + - name: Start and verify application + shell: bash + run: | + set -euo pipefail + compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \ + -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml) + "${compose[@]}" up -d --no-build --wait codex-proxy-rs + status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + "http://127.0.0.1:${CPR_SMOKE_PORT}/healthz")" + test "$status" = 204 + test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001 + test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001 + "${compose[@]}" exec -T codex-proxy-rs sh -c \ + 'touch /app/data/.ci-write /app/logs/.ci-write && rm /app/data/.ci-write /app/logs/.ci-write' + + - name: Verify clean logs and graceful shutdown + shell: bash + run: | + set -euo pipefail + compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \ + -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml) + "${compose[@]}" stop --timeout 30 codex-proxy-rs + container_id="$("${compose[@]}" ps -a -q codex-proxy-rs)" + test -n "$container_id" + test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = 0 + "${compose[@]}" logs --no-color --timestamps codex-proxy-rs \ + > "$DIAGNOSTICS_DIR/application.log" + "${compose[@]}" logs --no-color --timestamps postgres \ + > "$DIAGNOSTICS_DIR/postgres.log" + "${compose[@]}" logs --no-color --timestamps redis \ + > "$DIAGNOSTICS_DIR/redis.log" + if grep --extended-regexp --ignore-case \ + '"level"[[:space:]]*:[[:space:]]*"(warn|error|fatal|panic)"|(^|[^[:alnum:]_])panic([^[:alnum:]_]|$)' \ + "$DIAGNOSTICS_DIR/application.log" \ + || grep --extended-regexp --ignore-case \ + '(^|[[:space:]])(warning|error|fatal|panic):' \ + "$DIAGNOSTICS_DIR/postgres.log" \ + || grep --extended-regexp --ignore-case \ + '[0-9]+:[A-Z][[:space:]].*[[:space:]]#[[:space:]]|(^|[|[:space:]])(warning|error|fatal|panic)(:|[[:space:]])' \ + "$DIAGNOSTICS_DIR/redis.log"; then + echo "container logs contain a warning or error severity" >&2 + exit 1 + fi + + - name: Collect container diagnostics + if: always() + shell: bash + run: | + set +e + mkdir -p "$DIAGNOSTICS_DIR" + raw_dir="$(mktemp -d)" + compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \ + -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml) + "${compose[@]}" config > "$raw_dir/compose-config.yaml" 2>&1 + "${compose[@]}" ps -a > "$raw_dir/compose-ps.txt" 2>&1 + "${compose[@]}" logs --no-color --timestamps > "$raw_dir/compose.log" 2>&1 + mapfile -t container_ids < <("${compose[@]}" ps -a -q) + if [ "${#container_ids[@]}" -gt 0 ]; then + docker inspect "${container_ids[@]}" > "$raw_dir/docker-inspect.json" 2>&1 + fi + RAW_DIR="$raw_dir" OUTPUT_DIR="$DIAGNOSTICS_DIR" ruby <<'RUBY' + raw_dir = ENV.fetch("RAW_DIR") + output_dir = ENV.fetch("OUTPUT_DIR") + secrets = [ + ENV.fetch("CPR_ADMIN_DEFAULT_PASSWORD"), + ENV.fetch("CPR_POSTGRES_PASSWORD"), + ENV.fetch("CPR_REDIS_PASSWORD"), + ] + encoded_secrets = secrets.map do |secret| + secret.encode("UTF-8").bytes.map do |byte| + character = byte.chr + character.match?(/[A-Za-z0-9]/) ? character : format("%%%02X", byte) + end.join + end + + Dir.children(raw_dir).each do |name| + content = File.binread(File.join(raw_dir, name)) + (secrets + encoded_secrets).each do |secret| + content = content.gsub(secret, "") + end + File.binwrite(File.join(output_dir, name), content) + end + RUBY + rm -rf "$raw_dir" + cp trivy-results.sarif "$DIAGNOSTICS_DIR/" 2>/dev/null || true + + - name: Upload container diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/codex-proxy-rs-diagnostics + if-no-files-found: warn + retention-days: 7 + + - name: Remove isolated runtime + if: always() + shell: bash + run: | + docker compose --project-name "$COMPOSE_PROJECT_NAME" \ + -f deploy/docker-compose.yml -f deploy/docker-compose.smoke.yml \ + down --volumes --remove-orphans || true + sudo rm -rf "$GITHUB_WORKSPACE/.runtime/ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml new file mode 100644 index 000000000..38dd91bad --- /dev/null +++ b/.github/workflows/_quality.yml @@ -0,0 +1,137 @@ +name: Reusable Quality + +on: + workflow_call: + inputs: + ref: + description: Exact commit SHA to validate + required: true + type: string + upload_frontend: + description: Upload the built frontend for release jobs + required: false + default: false + type: boolean + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + CARGO_TERM_COLOR: always + CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy + CPR_TEST_REDIS_URL: redis://127.0.0.1:6379 + RUSTFLAGS: -D warnings + services: + postgres: + image: postgres:18-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15 + env: + POSTGRES_DB: codex_proxy + POSTGRES_USER: codex_proxy + POSTGRES_PASSWORD: codex_proxy + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U codex_proxy -d codex_proxy" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.ref }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95 + with: + toolchain: '1.95' + components: rustfmt,clippy + + - name: Cache Rust + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: backend -> target + + - name: Rust format + working-directory: backend + run: cargo fmt --check + + - name: Rust clippy + working-directory: backend + run: cargo clippy --all-targets --all-features --locked -- -D warnings + + - name: Rust tests + working-directory: backend + run: cargo test --test main --locked + + frontend: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CI: 'true' + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + version: 11.7.0 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install frontend dependencies + run: pnpm --dir frontend install --frozen-lockfile + + - name: Frontend format + run: pnpm --dir frontend format:check + + - name: Frontend build + run: pnpm --dir frontend build + + - name: Upload frontend artifact + if: inputs.upload_frontend + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: frontend-dist + path: frontend/dist + if-no-files-found: error + retention-days: 1 + + workflow-lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.ref }} + + - name: Lint GitHub Actions workflows + run: >- + docker run --rm + --volume "$PWD:/repo" + --workdir /repo + rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9 + -color + .github/workflows/*.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a42f1e1f1..cc028d8db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,96 +10,47 @@ permissions: contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: - backend: + changes: runs-on: ubuntu-latest - services: - postgres: - image: postgres:18-alpine - env: - POSTGRES_DB: codex_proxy - POSTGRES_USER: codex_proxy - POSTGRES_PASSWORD: codex_proxy - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U codex_proxy -d codex_proxy" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - redis: - image: redis:8-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 5s - --health-retries 10 + timeout-minutes: 5 + outputs: + container: ${{ steps.filter.outputs.container }} steps: - name: Checkout - uses: actions/checkout@v7 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@1.95 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - components: rustfmt,clippy + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 - - name: Cache Rust - uses: Swatinem/rust-cache@v2.9.1 + - name: Detect container changes + id: filter + uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 with: - workspaces: backend -> target + filters: | + container: + - 'backend/**' + - 'frontend/**' + - 'deploy/**' + - '.dockerignore' + - 'release/version.yaml' + - '.github/workflows/_container.yml' + - '.github/workflows/ci.yml' - - name: Rust format - working-directory: backend - run: cargo fmt --check + quality: + uses: ./.github/workflows/_quality.yml + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: Rust clippy - working-directory: backend - run: cargo clippy --all-targets --all-features --locked -- -D warnings - - - name: Rust tests - working-directory: backend - run: cargo test --test main --locked - - frontend: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 11.7.0 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Install frontend dependencies - run: pnpm --dir frontend install --frozen-lockfile - - - name: Frontend format - run: pnpm --dir frontend format:check - - - name: Frontend build - run: pnpm --dir frontend build - - deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Validate Docker Compose - run: docker compose -f deploy/docker-compose.yml config - - - name: Build runtime image - run: docker build -f deploy/Dockerfile --target runtime -t codex-proxy-rs:ci . + container: + needs: [changes, quality] + if: needs.changes.outputs.container == 'true' + permissions: + contents: read + security-events: write + uses: ./.github/workflows/_container.yml + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69161d65c..3382028b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ env: jobs: release-info: runs-on: ubuntu-latest + timeout-minutes: 10 outputs: tag: ${{ steps.version.outputs.tag }} version: ${{ steps.version.outputs.version }} @@ -35,10 +36,11 @@ jobs: 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@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} fetch-depth: 0 @@ -151,62 +153,33 @@ jobs: 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 - build-frontend: + quality: needs: [release-info] - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - ref: ${{ needs.release-info.outputs.tag }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 11.7.0 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile - working-directory: frontend - - - name: Build frontend - run: pnpm build - working-directory: frontend - - - name: Upload frontend artifact - uses: actions/upload-artifact@v7 - with: - name: frontend-dist - path: frontend/dist - if-no-files-found: error - retention-days: 1 + 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] + needs: [release-info, quality] 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@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ needs.release-info.outputs.tag }} + ref: ${{ needs.release-info.outputs.git_sha }} - name: Cache Cargo - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.cargo/registry @@ -232,9 +205,8 @@ jobs: -v "$HOME/.cargo:/cargo" \ -v "$PWD/..:/workspace" \ -w /workspace/backend \ - rust:1.95-bookworm \ + rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 \ 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: @@ -244,7 +216,7 @@ jobs: CPR_BUILD_TYPE: release - name: Upload binary artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }} path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/ @@ -253,20 +225,27 @@ jobs: build-binary: name: build-binary (${{ matrix.os }}-${{ matrix.arch }}) - needs: [release-info] + needs: [release-info, quality] 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@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ needs.release-info.outputs.tag }} + ref: ${{ needs.release-info.outputs.git_sha }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95 + with: + toolchain: '1.95' + targets: ${{ matrix.target }} - name: Cache Cargo - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.cargo/registry @@ -276,10 +255,6 @@ jobs: restore-keys: | cargo-${{ runner.os }}-${{ matrix.target }}- - - name: Install Rust target - run: rustup target add ${{ matrix.target }} - working-directory: backend - - name: Build binary shell: bash run: | @@ -294,7 +269,7 @@ jobs: CPR_BUILD_TYPE: release - name: Upload binary artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }} path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/ @@ -302,8 +277,9 @@ jobs: retention-days: 1 package-assets: - needs: [release-info, build-frontend, build-docker-binary, build-binary] + needs: [release-info, quality, build-docker-binary, build-binary] runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read id-token: write @@ -311,18 +287,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ needs.release-info.outputs.tag }} + ref: ${{ needs.release-info.outputs.git_sha }} - name: Download frontend artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: frontend-dist path: dist/frontend - name: Download binary artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: codex-proxy-rs-* path: dist/binaries @@ -407,12 +383,12 @@ jobs: PY - name: Generate release asset provenance - uses: actions/attest@v4 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4 with: subject-path: dist/release/* - name: Upload release assets - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-assets path: dist/release @@ -421,30 +397,34 @@ jobs: build-docker-image: name: build-docker-image (${{ matrix.dockerPlatform }}) - needs: [release-info, build-frontend, build-docker-binary] + 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@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ needs.release-info.outputs.tag }} + ref: ${{ needs.release-info.outputs.git_sha }} - name: Download frontend artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: frontend-dist path: dist/docker/web/dist - name: Download Docker binary artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }} path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }} @@ -476,10 +456,10 @@ jobs: PY - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -487,7 +467,7 @@ jobs: - name: Build and push app image id: build-and-push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: deploy/Dockerfile @@ -505,13 +485,24 @@ jobs: 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 }}:${{ needs.release-info.outputs.version }}-${{ matrix.arch }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }}-${{ matrix.arch }} + 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 + 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 @@ -520,24 +511,24 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ needs.release-info.outputs.tag }} + ref: ${{ needs.release-info.outputs.git_sha }} - name: Download release assets - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: release-assets path: dist/release - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Install Cosign - uses: sigstore/cosign-installer@v4.1.2 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -550,6 +541,7 @@ jobs: 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 @@ -560,7 +552,7 @@ jobs: platform="${platform//[[:space:]]/}" [ -n "${platform}" ] || continue arch="${platform##*/}" - sources+=("${image}:${VERSION}-${arch}") + sources+=("${image}:sha-${GIT_SHA}-${arch}") done if [ "${#sources[@]}" -eq 0 ]; then @@ -568,11 +560,14 @@ jobs: exit 1 fi - docker buildx imagetools create \ - -t "${image}:${VERSION}" \ - -t "${image}:latest" \ - -t "${image}:sha-${GIT_SHA}" \ - "${sources[@]}" + 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}")" @@ -592,7 +587,7 @@ jobs: echo "digest=${digest}" >> "$GITHUB_OUTPUT" - name: Generate container provenance attestation - uses: actions/attest@v4 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-digest: ${{ steps.manifest.outputs.digest }} @@ -602,10 +597,9 @@ jobs: shell: bash env: DIGEST: ${{ steps.manifest.outputs.digest }} - IMAGE_TAGS: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.release-info.outputs.git_sha }} + 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 @@ -613,20 +607,26 @@ jobs: exit 1 fi + image="${REGISTRY}/${IMAGE_NAME}" + tags=("${image}:${VERSION}" "${image}:sha-${GIT_SHA}") + if [ "$PRERELEASE" != true ]; then + tags+=("${image}:latest") + fi images=() - while IFS= read -r tag; do - [ -n "${tag}" ] || continue + for tag in "${tags[@]}"; do images+=("${tag}@${DIGEST}") - done <<< "${IMAGE_TAGS}" + done cosign sign --yes "${images[@]}" - name: Publish GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 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 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 9eda42f5b..9b408f2a3 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -12,7 +12,7 @@ permissions: contents: read concurrency: - group: security-scan-${{ github.workflow }}-${{ github.ref }} + group: security-scan-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: @@ -21,18 +21,22 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Setup Rust - uses: dtolnay/rust-toolchain@1.95 + uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95 + with: + toolchain: '1.95' - name: Cache Rust - uses: Swatinem/rust-cache@v2.9.1 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: backend -> target - name: Install cargo-audit - run: cargo install cargo-audit --locked + run: cargo install cargo-audit --version 0.22.2 --locked - name: Run cargo audit working-directory: backend @@ -43,15 +47,17 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Setup pnpm - uses: pnpm/action-setup@v6 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24 cache: pnpm @@ -63,21 +69,11 @@ jobs: - name: Run pnpm audit run: pnpm --dir frontend audit --prod --audit-level=high - docker-security: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Build runtime image - run: docker build -f deploy/Dockerfile --target runtime -t codex-proxy-rs:security-scan . - - - name: Scan runtime image - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: codex-proxy-rs:security-scan - severity: HIGH,CRITICAL - vuln-type: os,library - ignore-unfixed: true - exit-code: '1' + container-security: + if: github.event_name == 'schedule' + permissions: + contents: read + security-events: write + uses: ./.github/workflows/_container.yml + with: + ref: ${{ github.sha }} diff --git a/.gitignore b/.gitignore index b046d87e8..c619267e2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # Frontend dependencies and build output. /frontend/node_modules/ /frontend/dist/ +.pnpm-store # Application runtime state. *.runtime diff --git a/README.md b/README.md index d7a394426..76f5ca286 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,19 @@ PostgreSQL 与 Redis 分别使用 `postgres-data`、`redis-data` 命名卷。 ```bash mkdir -p .runtime/data .runtime/logs cp deploy/config.example.yaml .runtime/config.yaml -# 编辑 .runtime/config.yaml,设置 admin.default_password +sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs +chmod 0770 .runtime/data .runtime/logs +sudo chown "$(id -u):10001" .runtime/config.yaml +chmod 0640 .runtime/config.yaml +cp deploy/.env.example deploy/.env +chmod 0600 deploy/.env +# 编辑 deploy/.env,设置管理员、PostgreSQL 与 Redis 密码 ``` +`deploy/.env` 是 Docker 部署的密钥配置文件,已被 Git 忽略。Compose 要求其中三项密码均为非空; +应用会用管理员密码覆盖 `admin.default_password`,并安全覆盖数据库 URL 的密码部分,不需要手工编码或 +同步连接串。编辑密码时保留示例中的单引号。 + Docker 配置文件里路径保持容器路径: ```yaml @@ -116,8 +126,8 @@ telemetry: 构建并启动: ```bash -docker compose -f deploy/docker-compose.yml build -docker compose -f deploy/docker-compose.yml up -d +docker compose --env-file deploy/.env -f deploy/docker-compose.yml build +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d ``` 默认只绑定宿主机 `127.0.0.1:8080`。如需覆盖路径: @@ -126,7 +136,7 @@ docker compose -f deploy/docker-compose.yml up -d CPR_CONFIG_FILE=/path/to/config.yaml \ CPR_DATA_DIR=/path/to/data \ CPR_LOG_DIR=/path/to/logs \ -docker compose -f deploy/docker-compose.yml up -d +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d ``` ## API @@ -228,7 +238,7 @@ cargo fmt --manifest-path backend/Cargo.toml --check cargo clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings cargo test --manifest-path backend/Cargo.toml --test main --locked pnpm --dir frontend build -docker compose -f deploy/docker-compose.yml config +docker compose --env-file deploy/.env -f deploy/docker-compose.yml config ``` ## License diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b5962f51d..fd12ceef6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -365,6 +365,7 @@ dependencies = [ "hex", "indexmap", "ipnet", + "percent-encoding", "rand 0.10.1", "rand_core 0.6.4", "redis", @@ -391,6 +392,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tungstenite", + "url", "uuid", "wiremock", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c17fc58e1..5070ee970 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -19,6 +19,7 @@ futures = "0.3.32" hex = "0.4.3" indexmap = { version = "2.0", features = ["serde"] } ipnet = "2.12.0" +percent-encoding = "2.3.2" rand = "0.10.1" rand_core = { version = "0.6.4", features = ["getrandom"] } redis = { version = "1.3.0", default-features = false, features = ["tokio-comp", "connection-manager", "script"] } @@ -44,6 +45,7 @@ tracing = "0.1.44" tracing-appender = "0.2.5" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] } +url = "2.5.8" uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] } [dev-dependencies] diff --git a/backend/src/bootstrap/config.rs b/backend/src/bootstrap/config.rs index f3853d479..cc2d08f78 100644 --- a/backend/src/bootstrap/config.rs +++ b/backend/src/bootstrap/config.rs @@ -1,6 +1,8 @@ use std::{collections::BTreeMap, env, path::Path}; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use serde::{Deserialize, Serialize}; +use url::Url; const WEAK_ADMIN_PASSWORDS: &[&str] = &[ "", @@ -404,9 +406,12 @@ impl Default for TelemetryConfig { } const CONFIG_FILE_ENV: &str = "CPR_CONFIG_FILE"; +const ADMIN_DEFAULT_PASSWORD_ENV: &str = "CPR_ADMIN_DEFAULT_PASSWORD"; +const POSTGRES_PASSWORD_ENV: &str = "CPR_POSTGRES_PASSWORD"; +const REDIS_PASSWORD_ENV: &str = "CPR_REDIS_PASSWORD"; impl AppConfig { - /// 从 `CPR_CONFIG_FILE` 或当前目录 `config.yaml` 加载配置文件。 + /// 从 `CPR_CONFIG_FILE` 或当前目录 `config.yaml` 加载配置文件,并应用运行时密码。 pub fn load() -> Result { let config_file = env::var(CONFIG_FILE_ENV) .ok() @@ -417,15 +422,61 @@ impl AppConfig { } } - /// 从指定目录加载配置文件。 + /// 从指定目录加载配置文件,并应用运行时密码。 pub fn load_from_dir(config_dir: impl AsRef) -> Result { load_file(config_dir.as_ref().join("config.yaml")) } } fn load_file(config_file: impl AsRef) -> Result { - ::config::Config::builder() + let mut app_config: AppConfig = ::config::Config::builder() .add_source(::config::File::from(config_file.as_ref()).required(true)) .build()? - .try_deserialize() + .try_deserialize()?; + apply_password_override( + &mut app_config.database.url, + "database.url", + POSTGRES_PASSWORD_ENV, + )?; + apply_password_override(&mut app_config.redis.url, "redis.url", REDIS_PASSWORD_ENV)?; + if let Some(password) = password_override(ADMIN_DEFAULT_PASSWORD_ENV)? { + app_config.admin.default_password = password; + } + Ok(app_config) +} + +fn apply_password_override( + raw_url: &mut String, + config_key: &str, + env_name: &str, +) -> Result<(), ::config::ConfigError> { + let Some(password) = password_override(env_name)? else { + return Ok(()); + }; + let mut url = Url::parse(raw_url).map_err(|_| { + ::config::ConfigError::Message(format!( + "{config_key} must be a valid URL when {env_name} is set" + )) + })?; + let encoded_password = utf8_percent_encode(&password, NON_ALPHANUMERIC).to_string(); + url.set_password(Some(&encoded_password)).map_err(|()| { + ::config::ConfigError::Message(format!( + "{config_key} does not support a password override from {env_name}" + )) + })?; + *raw_url = url.into(); + Ok(()) +} + +fn password_override(env_name: &str) -> Result, ::config::ConfigError> { + match env::var(env_name) { + Ok(password) if password.is_empty() => Err(::config::ConfigError::Message(format!( + "{env_name} must not be empty" + ))), + Ok(password) => Ok(Some(password)), + Err(env::VarError::NotPresent) => Ok(None), + Err(env::VarError::NotUnicode(_)) => Err(::config::ConfigError::Message(format!( + "{env_name} must contain valid UTF-8" + ))), + } } diff --git a/backend/tests/bootstrap/config/mod.rs b/backend/tests/bootstrap/config/mod.rs index 9caa806ae..d5567dac5 100644 --- a/backend/tests/bootstrap/config/mod.rs +++ b/backend/tests/bootstrap/config/mod.rs @@ -2,7 +2,7 @@ use codex_proxy_rs::bootstrap::config::LoggingConfig; use codex_proxy_rs::bootstrap::config::{AdminConfig, AdminConfigError, AppConfig}; use codex_proxy_rs::bootstrap::services::account_pool_options_from_config; use serde::de::DeserializeOwned; -use std::fs; +use std::{fs, process::Command}; const DEFAULT_CONFIG_YAML: &str = r#" server: @@ -179,6 +179,14 @@ redis: #[test] fn config_loader_should_read_only_config_yaml() { + const CASE_ENV: &str = "CODEX_PROXY_TEST_CONFIG_FILE_ONLY_CASE"; + if std::env::var(CASE_ENV).as_deref() != Ok("child") { + run_isolated_config_test("config_loader_should_read_only_config_yaml", |command| { + command.env(CASE_ENV, "child"); + }); + return; + } + let dir = tempfile::tempdir().unwrap(); fs::write( dir.path().join("config.yaml"), @@ -315,6 +323,14 @@ fn server_config_should_reject_unknown_fields() { #[test] fn config_loader_should_read_explicit_config_file_from_env() { + const CASE_ENV: &str = "CODEX_PROXY_TEST_EXPLICIT_CONFIG_FILE_CASE"; + if std::env::var(CASE_ENV).as_deref() == Ok("child") { + let cfg = AppConfig::load().unwrap(); + assert_eq!(cfg.server.host, "127.0.0.2"); + assert_eq!(cfg.logging.file.directory, ".runtime/env-logs"); + return; + } + let dir = tempfile::tempdir().unwrap(); let config_file = dir.path().join("custom-config.yaml"); fs::write( @@ -324,13 +340,120 @@ fn config_loader_should_read_explicit_config_file_from_env() { .replace("directory: .runtime/logs", "directory: .runtime/env-logs"), ) .unwrap(); - std::env::set_var("CPR_CONFIG_FILE", &config_file); + run_isolated_config_test( + "config_loader_should_read_explicit_config_file_from_env", + |command| { + command + .env(CASE_ENV, "child") + .env("CPR_CONFIG_FILE", &config_file); + }, + ); +} - let cfg = AppConfig::load().unwrap(); +#[test] +fn config_loader_should_override_runtime_passwords_from_env() { + const CASE_ENV: &str = "CODEX_PROXY_TEST_CONFIG_PASSWORD_OVERRIDE_CASE"; + if std::env::var(CASE_ENV).as_deref() != Ok("child") { + run_isolated_config_test( + "config_loader_should_override_runtime_passwords_from_env", + |command| { + command + .env(CASE_ENV, "child") + .env("CPR_ADMIN_DEFAULT_PASSWORD", "admin @:/?#% password") + .env("CPR_POSTGRES_PASSWORD", "pg @:/?#%") + .env("CPR_REDIS_PASSWORD", "redis @:/?#%"); + }, + ); + return; + } - std::env::remove_var("CPR_CONFIG_FILE"); - assert_eq!(cfg.server.host, "127.0.0.2"); - assert_eq!(cfg.logging.file.directory, ".runtime/env-logs"); + let dir = write_default_config(); + let cfg = AppConfig::load_from_dir(dir.path()).unwrap(); + + assert_eq!( + cfg.database.url, + "postgres://codex_proxy:pg%20%40%3A%2F%3F%23%25@127.0.0.1:5432/codex_proxy" + ); + assert_eq!( + cfg.redis.url, + "redis://:redis%20%40%3A%2F%3F%23%25@127.0.0.1:6379" + ); + assert_eq!(cfg.admin.default_password, "admin @:/?#% password"); +} + +#[test] +fn config_loader_should_reject_empty_password_env() { + const CASE_ENV: &str = "CODEX_PROXY_TEST_EMPTY_CONFIG_PASSWORD_CASE"; + if std::env::var(CASE_ENV).as_deref() != Ok("child") { + run_isolated_config_test( + "config_loader_should_reject_empty_password_env", + |command| { + command + .env(CASE_ENV, "child") + .env("CPR_POSTGRES_PASSWORD", ""); + }, + ); + return; + } + + let dir = write_default_config(); + let error = AppConfig::load_from_dir(dir.path()).unwrap_err(); + + assert_eq!(error.to_string(), "CPR_POSTGRES_PASSWORD must not be empty"); +} + +#[test] +fn config_loader_should_reject_empty_admin_password_env() { + const CASE_ENV: &str = "CODEX_PROXY_TEST_EMPTY_ADMIN_PASSWORD_CASE"; + if std::env::var(CASE_ENV).as_deref() != Ok("child") { + run_isolated_config_test( + "config_loader_should_reject_empty_admin_password_env", + |command| { + command + .env(CASE_ENV, "child") + .env("CPR_ADMIN_DEFAULT_PASSWORD", ""); + }, + ); + return; + } + + let dir = write_default_config(); + let error = AppConfig::load_from_dir(dir.path()).unwrap_err(); + + assert_eq!( + error.to_string(), + "CPR_ADMIN_DEFAULT_PASSWORD must not be empty" + ); +} + +#[test] +fn config_loader_should_not_expose_password_when_url_is_invalid() { + const CASE_ENV: &str = "CODEX_PROXY_TEST_INVALID_CONFIG_URL_CASE"; + const SECRET: &str = "do-not-leak-this-password"; + if std::env::var(CASE_ENV).as_deref() != Ok("child") { + run_isolated_config_test( + "config_loader_should_not_expose_password_when_url_is_invalid", + |command| { + command + .env(CASE_ENV, "child") + .env("CPR_REDIS_PASSWORD", SECRET); + }, + ); + return; + } + + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("config.yaml"), + DEFAULT_CONFIG_YAML.replace("redis://127.0.0.1:6379", "not a URL"), + ) + .unwrap(); + let error = AppConfig::load_from_dir(dir.path()).unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("redis.url")); + assert!(message.contains("CPR_REDIS_PASSWORD")); + assert!(!message.contains(SECRET)); } #[test] @@ -362,3 +485,31 @@ fn parse_yaml_config(yaml: &str) -> Result tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("config.yaml"), DEFAULT_CONFIG_YAML).unwrap(); + dir +} + +fn run_isolated_config_test(test_name: &str, configure: impl FnOnce(&mut Command)) { + let current_exe = std::env::current_exe().expect("current test binary path"); + let mut command = Command::new(current_exe); + command + .arg("--exact") + .arg(format!("bootstrap::config::{test_name}")) + .arg("--nocapture") + .env_remove("CPR_CONFIG_FILE") + .env_remove("CPR_ADMIN_DEFAULT_PASSWORD") + .env_remove("CPR_POSTGRES_PASSWORD") + .env_remove("CPR_REDIS_PASSWORD"); + configure(&mut command); + + let output = command.output().expect("run isolated config test case"); + assert!( + output.status.success(), + "isolated config test {test_name} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 000000000..69eff9b48 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,4 @@ +# Docker deployment secrets. Copy this file to deploy/.env and set every value. +CPR_ADMIN_DEFAULT_PASSWORD='' +CPR_POSTGRES_PASSWORD='' +CPR_REDIS_PASSWORD='' diff --git a/deploy/Dockerfile b/deploy/Dockerfile index d11701d19..17a0aad25 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,6 +1,6 @@ -# syntax=docker/dockerfile:1.7 +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e -FROM node:24-bookworm-slim AS frontend-builder +FROM node:24-bookworm-slim@sha256:cb4e8f7c443347358b7875e717c29e27bf9befc8f5a26cf18af3c3dec80e58c5 AS frontend-builder WORKDIR /app/frontend COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ @@ -8,7 +8,7 @@ RUN corepack enable && pnpm install --frozen-lockfile COPY frontend ./ RUN pnpm build -FROM rust:1.95-bookworm AS backend-builder +FROM rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 AS backend-builder WORKDIR /app/backend ARG TARGETARCH @@ -38,7 +38,10 @@ ARG TARGETARCH WORKDIR /out COPY --from=frontend-builder /app/frontend/dist /tmp/release/codex-proxy-rs/web/dist -RUN cp /tmp/codex-proxy-rs /tmp/release/codex-proxy-rs/ \ +RUN test -n "${CPR_VERSION}" \ + && test -n "${TARGETOS}" \ + && test -n "${TARGETARCH}" \ + && cp /tmp/codex-proxy-rs /tmp/release/codex-proxy-rs/ \ && tar -czf "/out/codex-proxy-rs_${CPR_VERSION}_${TARGETOS}_${TARGETARCH}.tar.gz" \ -C /tmp/release codex-proxy-rs @@ -46,7 +49,7 @@ FROM scratch AS release-asset COPY --from=release-asset-builder /out / -FROM debian:bookworm-slim AS runtime-base +FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df AS runtime-base RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ diff --git a/deploy/README.md b/deploy/README.md index 97355fffb..2889ea4f3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -9,6 +9,12 @@ ```bash mkdir -p .runtime/data .runtime/logs cp deploy/config.example.yaml .runtime/config.yaml +sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs +chmod 0770 .runtime/data .runtime/logs +sudo chown "$(id -u):10001" .runtime/config.yaml +chmod 0640 .runtime/config.yaml +cp deploy/.env.example deploy/.env +chmod 0600 deploy/.env ``` `deploy/config.example.yaml` 是 Docker 部署模板。Compose 默认使用仓库根目录的 `.runtime/`: @@ -20,20 +26,24 @@ cp deploy/config.example.yaml .runtime/config.yaml 应用日志同时写入 `docker logs` 与 `.runtime/logs`。Compose 对应用、PostgreSQL、Redis 统一启用 `json-file` 的 `10m × 5` 轮转;应用文件日志还受配置中的自然日、单文件大小和文件总数限制。 -复制后必须设置 `admin.default_password`。空值、长度不足 12 位或常见弱口令会被后端拒绝。 +`deploy/.env` 是 Docker 部署的密钥配置文件,已被 Git 忽略且必须保持 mode `0600`。其中 +`CPR_ADMIN_DEFAULT_PASSWORD`、`CPR_POSTGRES_PASSWORD`、`CPR_REDIS_PASSWORD` 三项均为必填, +留空时 Compose 会在启动前失败。管理员密码必须至少 12 位且不能是常见弱口令。 -Redis 默认密码为 `codex_proxy`,与 `deploy/config.example.yaml` 的连接串一致。生产部署通过 -`CPR_REDIS_PASSWORD` 覆盖后,必须同步更新 `.runtime/config.yaml` 中 URL 的密码(特殊字符需做 URL 编码)。 -本地 Rust 集成测试若复用该 Compose Redis,设置 -`CPR_TEST_REDIS_URL=redis://:codex_proxy@127.0.0.1:6379/`。 +Compose 会把 PostgreSQL、Redis 密码的同一个值同时传给服务端和应用;应用使用 URL API 覆盖连接串 +密码,并用管理员密码覆盖 `admin.default_password`。因此无需修改 `.runtime/config.yaml` 中的密码, +特殊字符也无需手工编码。本地 Rust 集成测试若复用该 Compose 服务,应使用与 `deploy/.env` 一致的 +测试连接串。 +编辑密码时保留 `deploy/.env` 中的单引号,使 `#`、`$` 等 Compose 语法字符按字面值传递。 如果前面还有 Nginx、Caddy、Cloudflare Tunnel 等反向代理,确保反代正常传递 `CF-Connecting-IP`、`X-Real-IP` 或 `X-Forwarded-For`。后端会按这组头自动解析真实客户端 IP;没有这些头时回落到直连 peer IP。 构建并启动: ```bash -docker compose -f deploy/docker-compose.yml build -docker compose -f deploy/docker-compose.yml up -d +# 先编辑 deploy/.env,设置全部三项密码。 +docker compose --env-file deploy/.env -f deploy/docker-compose.yml build +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d ``` 可以通过环境变量覆盖宿主机路径。容器内始终通过 `CPR_CONFIG_FILE=/app/config.yaml` 读取挂载后的配置: @@ -42,7 +52,7 @@ docker compose -f deploy/docker-compose.yml up -d CPR_CONFIG_FILE=/path/to/config.yaml \ CPR_DATA_DIR=/path/to/data \ CPR_LOG_DIR=/path/to/logs \ -docker compose -f deploy/docker-compose.yml up -d +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d ``` ## 构建 @@ -56,7 +66,7 @@ docker build -f deploy/Dockerfile -t codex-proxy-rs:latest . 也可以用 Compose 构建: ```bash -docker compose -f deploy/docker-compose.yml build +docker compose --env-file deploy/.env -f deploy/docker-compose.yml build ``` 需要写入版本元数据时: @@ -65,5 +75,5 @@ docker compose -f deploy/docker-compose.yml build CPR_VERSION="$(ruby -ryaml -e 'puts YAML.load_file("release/version.yaml").fetch("version").delete_prefix("v")')" \ CPR_GIT_SHA="$(git rev-parse HEAD)" \ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -docker compose -f deploy/docker-compose.yml build +docker compose --env-file deploy/.env -f deploy/docker-compose.yml build ``` diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 4b1b50807..3d61d7ecc 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -84,7 +84,7 @@ fingerprint: admin: # 管理端登录会话有效期,单位分钟。 session_ttl_minutes: 1440 - # 首次启动创建的默认管理员账号。default_password 必须改成至少 12 位的非默认密码。 + # 首次启动创建的默认管理员账号。Docker 部署通过 CPR_ADMIN_DEFAULT_PASSWORD 注入密码。 default_username: admin@cpr.local default_password: '' diff --git a/deploy/docker-compose.smoke.yml b/deploy/docker-compose.smoke.yml new file mode 100644 index 000000000..a6ed40c1f --- /dev/null +++ b/deploy/docker-compose.smoke.yml @@ -0,0 +1,11 @@ +services: + postgres: + ports: !reset [] + + redis: + ports: !reset [] + + codex-proxy-rs: + container_name: ${COMPOSE_PROJECT_NAME}-app + ports: !override + - "127.0.0.1:${CPR_SMOKE_PORT:-18080}:8080" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 12ce7e183..708e0b927 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -8,12 +8,12 @@ x-json-logging: &json-logging services: postgres: - image: postgres:18-alpine + image: postgres:18-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15 restart: unless-stopped environment: POSTGRES_DB: codex_proxy POSTGRES_USER: codex_proxy - POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:-codex_proxy} + POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:?CPR_POSTGRES_PASSWORD must be set in deploy/.env} volumes: - postgres-data:/var/lib/postgresql ports: @@ -28,7 +28,7 @@ services: logging: *json-logging redis: - image: redis:8-alpine + image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005 restart: unless-stopped command: [ @@ -36,7 +36,7 @@ services: "--appendonly", "yes", "--requirepass", - "${CPR_REDIS_PASSWORD:-codex_proxy}", + "${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env}", ] volumes: - redis-data:/data @@ -49,7 +49,7 @@ services: "redis-cli", "--no-auth-warning", "-a", - "${CPR_REDIS_PASSWORD:-codex_proxy}", + "${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env}", "ping", ] interval: 5s @@ -105,6 +105,9 @@ services: HOME: /app XDG_DATA_HOME: /app/data CPR_CONFIG_FILE: /app/config.yaml + CPR_ADMIN_DEFAULT_PASSWORD: ${CPR_ADMIN_DEFAULT_PASSWORD:?CPR_ADMIN_DEFAULT_PASSWORD must be set in deploy/.env} + CPR_POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:?CPR_POSTGRES_PASSWORD must be set in deploy/.env} + CPR_REDIS_PASSWORD: ${CPR_REDIS_PASSWORD:?CPR_REDIS_PASSWORD must be set in deploy/.env} CPR_DEPLOYMENT_MODE: docker CPR_UPDATE_REPOSITORY: zyycn/codex-proxy-rs CPR_UPDATE_CHANNEL: stable diff --git a/docs/deploy-ci-review.md b/docs/deploy-ci-review.md index d0f366996..4b2d11e7b 100644 --- a/docs/deploy-ci-review.md +++ b/docs/deploy-ci-review.md @@ -170,16 +170,17 @@ push 与 pull request 使用和 CI 相同的 head repository、branch name concu ### 5.1 单一配置来源 -`AppConfig` 保持当前 YAML 结构,新增两个可选的运行时环境变量: +`AppConfig` 保持当前 YAML 结构,新增三个可选的运行时环境变量: +- `CPR_ADMIN_DEFAULT_PASSWORD` - `CPR_POSTGRES_PASSWORD` - `CPR_REDIS_PASSWORD` 加载顺序固定为: 1. 严格解析完整 YAML; -2. 读取两个可选环境变量; -3. 使用 `url::Url` 仅替换 `database.url`、`redis.url` 的 password 部分; +2. 读取三个可选环境变量; +3. 直接覆盖 `admin.default_password`,使用 `url::Url` 仅替换 `database.url`、`redis.url` 的 password 部分; 4. 返回最终 `AppConfig`。 禁止用字符串拼接或替换 URL。用户名、scheme、host、port、path、query 均保持 YAML 原值。密码按原始字节内容交给 URL API 编码,不 trim;空值视为无效配置。URL 解析失败、URL 不支持密码或环境变量为空时,返回明确但不包含密码内容的配置错误。 @@ -190,16 +191,17 @@ push 与 pull request 使用和 CI 相同的 head repository、branch name concu Compose 对数据库服务与应用服务使用完全相同的展开值: -- PostgreSQL 的 `POSTGRES_PASSWORD` 与应用的 `CPR_POSTGRES_PASSWORD` 都来自 `${CPR_POSTGRES_PASSWORD:-codex_proxy}`。 -- Redis 的 `--requirepass`、healthcheck 与应用的 `CPR_REDIS_PASSWORD` 都来自 `${CPR_REDIS_PASSWORD:-codex_proxy}`。 +- 管理员初始密码来自必填的 `CPR_ADMIN_DEFAULT_PASSWORD`; +- PostgreSQL 的 `POSTGRES_PASSWORD` 与应用的 `CPR_POSTGRES_PASSWORD` 都来自必填的 `CPR_POSTGRES_PASSWORD`; +- Redis 的 `--requirepass`、healthcheck 与应用的 `CPR_REDIS_PASSWORD` 都来自必填的 `CPR_REDIS_PASSWORD`。 -因此默认配置无需修改 URL;生产环境只设置两个 Compose 变量,不再人工编辑 YAML 密码。`deploy/config.example.yaml` 保留可独立运行的默认 URL,环境变量在加载后安全覆盖其中的 password。 +`deploy/.env` 定义为 Docker 部署唯一的密钥配置文件,必须被 Git 忽略且 mode 为 `0600`。三项变量任一缺失或为空时,Compose 在创建容器前失败。仓库提供仅含空值的 `deploy/.env.example`,不再人工编辑 YAML 密码;环境变量在加载后覆盖管理员密码和两个 URL 的 password。 ### 5.3 bind mount 权限 部署文档必须给出 Linux 首次初始化命令,并明确以下权限: -- `.runtime/data`、`.runtime/logs` 归 UID/GID `10001:10001` 所有且不可被其他用户写入; +- `.runtime/data`、`.runtime/logs` 归宿主部署用户所有,group 为容器 GID 10001,mode 为 `0770`;宿主文件共享进程可访问,容器用户可通过组权限写入,其他用户无权限; - `.runtime/config.yaml` 对容器 GID 10001 可读,对其他用户不可读; - 修改配置后保持原 owner、group 和 mode。 @@ -207,11 +209,11 @@ Compose 对数据库服务与应用服务使用完全相同的展开值: ## 6. Compose 烟测 -烟测使用临时工作目录和专用 Compose project,不能复用开发机 `.runtime/` 数据。配置文件通过 Ruby YAML API 从模板生成,写入至少 32 个 URL-safe 随机字符的管理员密码;禁止用 `sed` 修改 YAML。 +烟测使用临时工作目录、专用 Compose project 和 `deploy/docker-compose.smoke.yml` override,不能复用开发机 `.runtime/` 数据。override 移除 PostgreSQL、Redis 的宿主端口,按 project name 隔离应用容器名,并把应用映射到专用烟测端口。配置文件直接复制模板,管理员密码和数据库密码均通过专用的 CI 环境变量注入。 PostgreSQL 与 Redis 密码必须包含 `@`、`:`、`/`、`?`、`#`、`%` 中的多种字符,以证明 URL 覆盖正确编码。执行顺序如下: -1. 创建 owner 为 `10001:10001`、mode 为 `0750` 的数据和日志目录;配置文件 mode 为 `0640`,owner 为 runner UID,group 为 10001; +1. 创建 owner 为 runner UID、group 为 10001、mode 为 `0770` 的数据和日志目录;配置文件 mode 为 `0640`,owner 为 runner UID,group 为 10001; 2. 只启动 PostgreSQL、Redis 并等待健康; 3. 分别写入 PostgreSQL 表探针和 Redis key; 4. 强制重建 PostgreSQL、Redis 服务容器; @@ -220,7 +222,7 @@ PostgreSQL 与 Redis 密码必须包含 `@`、`:`、`/`、`?`、`#`、`%` 中的 7. 验证 `/healthz` 返回 HTTP 204; 8. 验证容器配置和进程实际 UID/GID 均为 `10001:10001`; 9. 验证 `/app/data`、`/app/logs` 可写; -10. 按日志级别或独立严重度单词扫描三个容器完整日志,不允许出现 WARN、WARNING、ERROR、FATAL、PANIC;字段名中的 `ops_error_logs` 等普通文本不构成失败; +10. 按日志级别扫描三个容器完整日志,不允许出现 WARN、WARNING、ERROR、FATAL、PANIC;应用检查结构化 `level`,PostgreSQL 检查 severity 前缀,Redis 检查 `#` warning marker 与独立严重度前缀,字段名中的 `ops_error_logs`、`bf-error-rate` 等普通文本不构成失败; 11. 向应用发送 SIGTERM,验证 30 秒内退出且退出码为 0。 失败时必须上传 Compose config、`docker compose ps -a`、inspect 结果和三个容器日志。清理步骤使用 `if: always()`,不得因前序失败遗留容器或命名卷。 @@ -318,3 +320,16 @@ Cosign 只签名本次实际创建的 tag。预发布版本不得在 manifest、 - 应用以非 root 身份健康启动并优雅退出; - Rust、前端、workflow、漏洞扫描和运行日志均达到本文定义的 0 错误、0 警告; - 所有测试代码位于 `backend/tests`,没有兼容路径和重复实现。 + +## 11. 实施验证结果 + +2026-07-11 本地验证结果: + +- `git diff --check`、Rust fmt、全目标全特性 clippy `-D warnings`、actionlint 与 YAML 解析通过; +- 后端使用真实 PostgreSQL、Redis 完成 `634 passed, 0 failed`;前端格式、类型检查、构建与生产依赖审计通过;Cargo audit 无漏洞; +- 空的 `deploy/.env.example` 会在 Compose 插值阶段失败,三项特殊字符密码能够完整传递; +- `runtime` 镜像构建成功,镜像 ID 为 `sha256:bc6c2ae76be06e2ac7b64b7629413c6f3c8a214100175d3fcc416ff578b7af24`,Trivy HIGH/CRITICAL 为 0; +- 独立 Compose 烟测得到 `health=204`、UID/GID `10001:10001`、PostgreSQL/Redis 持久化通过、退出码 0、日志无警告和错误; +- 本机部署已迁移到 mode `0600` 的 `deploy/.env`,YAML 管理员密码为空;只替换应用容器,PostgreSQL、Redis 容器和数据未变。 + +远端 reusable workflow 权限、SARIF、GHA cache、artifact 与 Release job 依赖仍须在本次提交推送后由 GitHub Actions 证明。