prodex 0.47.0

OpenAI profile pooling and safe auto-rotate for Codex CLI and Claude Code
Documentation
name: CI

on:
  push:
    branches:
      - main
  pull_request:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  fmt:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Check formatting
        run: cargo fmt --check

  docs-lint:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          package-manager-cache: false

      - name: Lint markdown docs
        run: npm run docs:lint

  secret-scan:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Scan repository for leaked credentials
        shell: bash
        run: |
          set -euo pipefail
          docker run --rm \
            -v "${PWD}:/repo" \
            ghcr.io/gitleaks/gitleaks:v8.30.1 \
            detect --source /repo --no-git --redact --no-banner --config /repo/.gitleaks.toml

  supply-chain:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2
        with:
          cache-bin: false

      - name: Cache cargo supply-chain tools
        id: cargo-supply-chain-cache
        uses: actions/cache@v5
        with:
          path: |
            ~/.cargo/bin/cargo-audit
            ~/.cargo/bin/cargo-deny
          key: ${{ runner.os }}-cargo-supply-chain-tools-audit-0.22.1-deny-0.19.0

      - name: Check locked compile graph
        run: cargo check --locked --all-targets --all-features

      - name: Run clippy warning gate
        run: cargo clippy --locked --all-targets --all-features -- -D warnings

      - name: Install cargo-audit
        shell: bash
        run: |
          set -euo pipefail
          if ! command -v cargo-audit >/dev/null 2>&1; then
            cargo install cargo-audit --locked --version 0.22.1
          fi

      - name: Run cargo audit
        run: cargo audit

      - name: Install cargo-deny
        shell: bash
        run: |
          set -euo pipefail
          if ! command -v cargo-deny >/dev/null 2>&1; then
            cargo install cargo-deny --locked --version 0.19.0
          fi

      - name: Run cargo deny checks
        run: cargo deny check advisories sources

  release-sync:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Verify release/version sync
        shell: bash
        run: |
          set -euo pipefail
          npm run npm:sync-version
          git diff --exit-code -- Cargo.toml npm README.md QUICKSTART.md scripts/npm

  npm-package-smoke:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Install Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          package-manager-cache: false

      - name: Build host release binary
        run: cargo build --release --locked --target x86_64-unknown-linux-gnu

      - name: Smoke staged npm package
        run: node scripts/ci/npm-package-smoke.mjs --binary-dir target/x86_64-unknown-linux-gnu/release

  auto-rotate:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Run auto-rotate integration tests
        run: cargo test --test auto_rotate

  profile-commands-internal:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Run profile command internal tests
        run: |
          cargo test --lib profile_commands_internal_tests:: -- --test-threads=1

  main-internal-core:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Run main internal tests without runtime proxy shard
        run: |
          set -euo pipefail
          cargo test --lib main_internal_tests:: -- --skip runtime_proxy_ --test-threads=1

  env-sensitive-parallel-guard:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Run env-sensitive parallel guard
        run: node scripts/ci/runtime-env-parallel.mjs --runs 2 --test-threads 4

  main-internal-runtime-proxy:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Run runtime proxy internal test shard
        run: node scripts/ci/runtime-proxy-shard.mjs

      - name: Upload runtime proxy shard diagnostics
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: runtime-proxy-shard-diagnostics
          path: target/ci/runtime-proxy
          if-no-files-found: ignore

  runtime-proxy-bench-smoke:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Check runtime proxy benchmark regression thresholds
        env:
          PRODEX_RUNTIME_PROXY_BENCH_CHECK: "1"
          PRODEX_RUNTIME_PROXY_BENCH_THRESHOLD_FILE: scripts/ci/runtime-proxy-bench-thresholds.json
        run: cargo bench --locked --features bench-support --bench runtime_proxy_hot_paths

  runtime-stress:
    name: Runtime stress (${{ matrix.label }})
    runs-on: ubuntu-latest
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        include:
          - suite: stress
            label: broad shard
          - suite: serialized
            label: serialized shard
          - suite: continuation
            label: continuation shard
    env:
      RUNTIME_STRESS_ARTIFACT_DIR: target/ci/runtime-stress/${{ matrix.suite }}
      PRODEX_RUNTIME_LOG_DIR: target/ci/runtime-stress/${{ matrix.suite }}/runtime-logs
      CARGO_TERM_COLOR: always
      RUST_BACKTRACE: "1"

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust dependencies
        uses: Swatinem/rust-cache@v2

      - name: Prepare runtime stress diagnostics
        shell: bash
        run: |
          set -euo pipefail
          rm -rf "${RUNTIME_STRESS_ARTIFACT_DIR}"
          mkdir -p "${PRODEX_RUNTIME_LOG_DIR}"
          {
            echo "suite=${{ matrix.suite }}"
            echo "label=${{ matrix.label }}"
            echo "artifact_dir=${RUNTIME_STRESS_ARTIFACT_DIR}"
            echo "runtime_log_dir=${PRODEX_RUNTIME_LOG_DIR}"
            echo "started_at=$(date -u +%FT%TZ)"
            echo "command=npm run ci:runtime-stress -- --suite ${{ matrix.suite }}"
          } > "${RUNTIME_STRESS_ARTIFACT_DIR}/metadata.txt"

      - name: Run runtime stress ${{ matrix.label }}
        shell: bash
        run: |
          set -euo pipefail
          npm run ci:runtime-stress -- --suite "${{ matrix.suite }}" 2>&1 | tee "${RUNTIME_STRESS_ARTIFACT_DIR}/runtime-stress.log"

      - name: Collect runtime stress diagnostics
        if: failure()
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p "${RUNTIME_STRESS_ARTIFACT_DIR}" "${PRODEX_RUNTIME_LOG_DIR}"
          diagnostics_path="${RUNTIME_STRESS_ARTIFACT_DIR}/diagnostics.txt"
          pointer_path="${PRODEX_RUNTIME_LOG_DIR}/prodex-runtime-latest.path"
          latest_log_path=""
          {
            echo "suite=${{ matrix.suite }}"
            echo "label=${{ matrix.label }}"
            echo "artifact_dir=${RUNTIME_STRESS_ARTIFACT_DIR}"
            echo "runtime_log_dir=${PRODEX_RUNTIME_LOG_DIR}"
            echo "finished_at=$(date -u +%FT%TZ)"
            echo "runtime_log_pointer=${pointer_path}"
          } > "${diagnostics_path}"
          if [[ -f "${pointer_path}" ]]; then
            latest_log_path="$(tr -d '\r\n' < "${pointer_path}")"
            echo "runtime_log_pointer_target=${latest_log_path}" >> "${diagnostics_path}"
          else
            echo "runtime_log_pointer_target=missing" >> "${diagnostics_path}"
          fi
          if [[ -z "${latest_log_path}" || ! -f "${latest_log_path}" ]]; then
            latest_log_path="$(
              find "${PRODEX_RUNTIME_LOG_DIR}" -maxdepth 1 -type f -name 'prodex-runtime*.log' -printf '%T@ %p\n' \
                | sort -nr \
                | head -n 1 \
                | cut -d' ' -f2-
            )"
          fi
          find "${PRODEX_RUNTIME_LOG_DIR}" -maxdepth 1 -type f -print \
            | sort \
            | sed 's/^/log_entry=/' >> "${diagnostics_path}"
          if [[ -n "${latest_log_path}" && -f "${latest_log_path}" ]]; then
            echo "latest_runtime_log=${latest_log_path}" >> "${diagnostics_path}"
            cp "${latest_log_path}" "${RUNTIME_STRESS_ARTIFACT_DIR}/latest-runtime.log"
            tail -n 200 "${latest_log_path}" | tee "${RUNTIME_STRESS_ARTIFACT_DIR}/latest-runtime-log-tail.txt"
          else
            echo "latest_runtime_log=missing" >> "${diagnostics_path}"
          fi

      - name: Upload runtime stress diagnostics
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: runtime-stress-${{ matrix.suite }}-diagnostics
          path: ${{ env.RUNTIME_STRESS_ARTIFACT_DIR }}
          if-no-files-found: ignore