diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml index 2fb9cc7f6..37ac74f88 100644 --- a/.github/workflows/build-matrix.yml +++ b/.github/workflows/build-matrix.yml @@ -1,213 +1,86 @@ -name: Build Matrix +name: Fork CI on: workflow_dispatch: pull_request: branches: - - "main" - push: - branches: - - "feature/build-matrix*" - - "feature/build-matrix/**" + - main + +permissions: + contents: read concurrency: - group: build-matrix-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: fork-ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: preflight: name: Preflight runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Node.js setup + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version + cache: npm - - name: Check locales - run: node scripts/check_locales.cjs + - name: Install dependencies + run: npm ci - - name: Typecheck - run: npm run typecheck + - name: Validate frontend + run: | + npm run sync-version + node scripts/check_locales.cjs + npm run typecheck - build: - name: Build (${{ matrix.label }}) + macos-arm64: + name: Build macOS arm64 needs: preflight - runs-on: ${{ matrix.platform }} - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - - label: "macos-aarch64" - platform: "macos-latest" - args: "--target aarch64-apple-darwin" - release_dir: "target/aarch64-apple-darwin/release" - - label: "macos-x86_64" - platform: "macos-latest" - args: "--target x86_64-apple-darwin" - release_dir: "target/x86_64-apple-darwin/release" - - label: "macos-universal" - platform: "macos-latest" - args: "--target universal-apple-darwin" - release_dir: "target/universal-apple-darwin/release" - - label: "ubuntu-22.04" - platform: "ubuntu-22.04" - args: "" - release_dir: "target/release" - - label: "ubuntu-24.04-arm" - platform: "ubuntu-24.04-arm" - args: "" - release_dir: "target/release" - - label: "windows-latest" - platform: "windows-latest" - args: "" - release_dir: "target/release" - + runs-on: macos-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install dependencies (Linux) - if: startsWith(matrix.platform, 'ubuntu') - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf pkg-config libsoup-3.0-dev javascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev - sudo apt-get install -y libnm-dev xdg-utils - - - name: Rust setup + - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + targets: aarch64-apple-darwin - - name: Go setup + - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: sidecars/cockpit-cliproxy/go.mod cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - name: Node.js setup + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Cache Tauri binary dependencies - uses: actions/cache@v4 - with: - path: | - ${{ matrix.platform == 'windows-latest' && 'C:\Users\runneradmin\AppData\Local\tauri' || '' }} - ${{ startsWith(matrix.platform, 'ubuntu') && '~/.cache/tauri' || '' }} - ${{ matrix.platform == 'macos-latest' && '~/Library/Caches/tauri' || '' }} - key: tauri-deps-${{ matrix.platform }}-${{ hashFiles('**/Cargo.toml') }} - restore-keys: | - tauri-deps-${{ matrix.platform }}- - - - name: Build app (pull request) - if: github.event_name == 'pull_request' - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - npx tauri build --ci --config src-tauri/tauri.ci.conf.json ${{ matrix.args }} 2>&1 | tee "build-${{ matrix.label }}.log" + cache: npm - - name: Print build logs on failure (PR) - if: failure() && github.event_name == 'pull_request' - shell: bash - run: | - echo "=== BUILD LOG (PR) ===" - if [ -f "build-${{ matrix.label }}.log" ]; then - tail -n 200 "build-${{ matrix.label }}.log" - else - echo "Log file not found" - fi + - name: Install dependencies + run: npm ci - - name: Build app (signed) - if: github.event_name != 'pull_request' - shell: bash + - name: Build app env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci ${{ matrix.args }} 2>&1 | tee "build-${{ matrix.label }}.log" - - - name: Print build logs on failure (Signed) - if: failure() && github.event_name != 'pull_request' - shell: bash - run: | - echo "=== BUILD LOG (SIGNED) ===" - if [ -f "build-${{ matrix.label }}.log" ]; then - tail -n 200 "build-${{ matrix.label }}.log" - else - echo "Log file not found" - fi - - - name: Summarize warnings - if: always() - shell: bash run: | set -euo pipefail - log_file="build-${{ matrix.label }}.log" - warnings_file="warnings-${{ matrix.label }}.txt" - if grep -n "^warning:" "$log_file" > "$warnings_file"; then - warning_count="$(wc -l < "$warnings_file" | tr -d ' ')" - else - : > "$warnings_file" - warning_count="0" - fi - - { - echo "## ${{ matrix.label }}" - echo - echo "- warnings: ${warning_count}" - echo "- log: build-${{ matrix.label }}.log" - if [ -s "$warnings_file" ]; then - echo - echo '```text' - sed -n '1,200p' "$warnings_file" - echo '```' - fi - } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload build logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: logs-${{ matrix.label }} - retention-days: 7 - if-no-files-found: error - path: | - build-${{ matrix.label }}.log - warnings-${{ matrix.label }}.txt - - - name: Upload bundles - if: success() + npm run sync-version + ./node_modules/.bin/tauri build \ + --ci \ + --config src-tauri/tauri.ci.conf.json \ + --target aarch64-apple-darwin \ + --bundles app 2>&1 | tee build-macos-arm64.log + + - name: Upload failure log + if: failure() uses: actions/upload-artifact@v4 with: - name: bundles-${{ matrix.label }} - retention-days: 7 - if-no-files-found: error - path: | - ${{ matrix.release_dir }}/bundle + name: fork-ci-macos-arm64-failure-log + retention-days: 3 + if-no-files-found: ignore + path: build-macos-arm64.log diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e24d3f2a..440cc1c9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,931 +1,120 @@ -name: Release +name: Fork Release on: + workflow_dispatch: push: tags: - - "v*" - workflow_dispatch: - -jobs: - prepare-release: - name: Prepare staged release - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - version: ${{ steps.app_version.outputs.VERSION }} - env: - GH_REPO: ${{ github.repository }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Read app version - id: app_version - shell: bash - run: | - set -euo pipefail - VERSION="$(node -e "console.log(require(require('path').join(process.env.GITHUB_WORKSPACE, 'package.json')).version)")" - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Validate tag matches version - shell: bash - run: | - set -euo pipefail - EXPECTED_TAG="v${{ steps.app_version.outputs.VERSION }}" - if [ "${GITHUB_REF_NAME}" != "${EXPECTED_TAG}" ]; then - echo "Tag (${GITHUB_REF_NAME}) does not match package.json version (${EXPECTED_TAG})." - exit 1 - fi - - - name: Build bilingual release notes from changelog - shell: bash - run: | - set -euo pipefail - VERSION="${{ steps.app_version.outputs.VERSION }}" - - extract_section() { - local file="$1" - local version="$2" - - awk -v version="$version" ' - BEGIN { - capture = 0 - found = 0 - } - - /^## / { - if (capture) { - exit - } - if ($0 ~ ("^## \\[" version "\\][[:space:]]*-.*$") || $0 ~ ("^## \\[v" version "\\][[:space:]]*-.*$")) { - capture = 1 - found = 1 - } - } + - "v*-jstar.*" - capture { - if ($0 ~ /^---[[:space:]]*$/) { - exit - } - print - } - - END { - if (!found) { - exit 2 - } - } - ' "$file" - } - - if ! zh_section="$(extract_section CHANGELOG.zh-CN.md "$VERSION")"; then - echo "Missing changelog section for version $VERSION in CHANGELOG.zh-CN.md" - exit 1 - fi - - if ! en_section="$(extract_section CHANGELOG.md "$VERSION")"; then - echo "Missing changelog section for version $VERSION in CHANGELOG.md" - exit 1 - fi +permissions: + contents: write - { - echo "## 更新日志(中文)" - echo - printf '%s\n' "$zh_section" - echo - echo "## Changelog (English)" - echo - printf '%s\n' "$en_section" - } > release-notes.md +concurrency: + group: fork-release-${{ github.ref }} + cancel-in-progress: false - date -u +"%Y-%m-%dT%H:%M:%SZ" > published-at.txt - - - name: Preserve previous legacy latest.json - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - PREVIOUS_TAG="$( - gh release list \ - --limit 100 \ - --json tagName,isDraft,isPrerelease \ - --jq "[.[] | select(.isDraft == false and .isPrerelease == false and .tagName != \"${GITHUB_REF_NAME}\")][0].tagName // \"\"" - )" - if [ -z "${PREVIOUS_TAG}" ]; then - echo "No previous published release was found; refusing to stage without a legacy updater manifest." - exit 1 - fi - - mkdir -p previous-release - gh release download "${PREVIOUS_TAG}" \ - --pattern "latest.json" \ - --dir previous-release \ - --clobber - test -s previous-release/latest.json - cp previous-release/latest.json legacy-latest.json - echo "Preserved legacy latest.json from ${PREVIOUS_TAG}" - - # Publish the release early (with previous complete latest.json) so parallel - # platform jobs can verify assets via public tag download URLs without - # waiting for Windows, and without reading the previous release's - # same-named target manifests through /releases/latest/. - - name: Create or update staged release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${{ steps.app_version.outputs.VERSION }}" - TITLE="Cockpit Tools v${{ steps.app_version.outputs.VERSION }}" - if gh release view "${TAG}" > /dev/null 2>&1; then - gh release edit "${TAG}" \ - --title "${TITLE}" \ - --notes-file release-notes.md - else - gh release create "${TAG}" \ - --draft \ - --title "${TITLE}" \ - --notes-file release-notes.md - fi - - IS_DRAFT="$(gh release view "${TAG}" --json isDraft --jq '.isDraft')" - if [ "${IS_DRAFT}" = "true" ]; then - cp legacy-latest.json latest.json - gh release upload "${TAG}" latest.json --clobber - gh release edit "${TAG}" --draft=false --prerelease=false --latest - echo "Published staged release ${TAG} with legacy latest.json" - else - # Re-run after a prior publish: do not clobber a finalized latest.json. - echo "Release ${TAG} is already published; leaving latest.json as-is" - gh release edit "${TAG}" --prerelease=false --latest - fi - - - name: Upload release metadata artifact - uses: actions/upload-artifact@v4 - with: - name: release-metadata - retention-days: 7 - if-no-files-found: error - path: | - release-notes.md - published-at.txt - legacy-latest.json - - build-windows: - name: Build Windows - needs: prepare-release - runs-on: windows-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Rust setup - uses: dtolnay/rust-toolchain@stable - - - name: Go setup - uses: actions/setup-go@v5 - with: - go-version-file: sidecars/cockpit-cliproxy/go.mod - cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build Windows app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata - - - name: Stage Windows release assets - shell: bash - run: | - set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform windows \ - --assets-dir "target/release/bundle" \ - --output-dir "release-assets" - - - name: Upload Windows release assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} Windows assets" - - - name: Build and upload Windows updater manifests - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - node scripts/release/build_target_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --targets "windows-x86_64-msi,windows-x86_64-nsis" \ - --output-dir "target-manifests" - gh release upload "v${VERSION}" target-manifests/*.json --clobber - - # Fallback only: prepare should already have published with legacy latest.json. - - name: Ensure staged release is published - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - IS_DRAFT="$(gh release view "${TAG}" --json isDraft --jq '.isDraft')" - if [ "${IS_DRAFT}" = "true" ]; then - cp release-metadata/legacy-latest.json latest.json - gh release upload "${TAG}" latest.json --clobber - gh release edit "${TAG}" --draft=false --prerelease=false --latest - echo "Published staged release from Windows fallback with legacy latest.json" - else - echo "Release is already published; keeping its current latest.json" - fi - - # Mid-stage verify uses the tag download URL so parallel jobs do not race - # on /releases/latest/ before this release is marked latest (or before - # other platforms finish). Public /latest/ is checked in finalize. - - name: Verify published Windows updater manifests - shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "windows-x86_64-msi,windows-x86_64-nsis" \ - --latest-base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - - build-macos-aarch64: - name: Build macOS Apple Silicon - # Parallel with Windows / macOS Intel after prepare (no hard platform deps). - needs: - - prepare-release +jobs: + macos-arm64: + name: Build macOS arm64 release runs-on: macos-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Rust setup + - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin - - name: Go setup - uses: actions/setup-go@v5 - with: - go-version-file: sidecars/cockpit-cliproxy/go.mod - cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build macOS Apple Silicon app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci --target aarch64-apple-darwin - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata - - - name: Stage macOS Apple Silicon assets - shell: bash - run: | - set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform macos \ - --mac-arch aarch64 \ - --assets-dir "target/aarch64-apple-darwin/release/bundle" \ - --output-dir "release-assets" - - - name: Upload macOS Apple Silicon assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} macOS Apple Silicon assets" - - - name: Build and upload macOS Apple Silicon updater manifest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - node scripts/release/build_target_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --targets "darwin-aarch64-app" \ - --output-dir "target-manifests" - gh release upload "v${VERSION}" target-manifests/*.json --clobber - - # Tag URL (not /releases/latest/) — see Windows verify comment. - - name: Verify published macOS Apple Silicon updater manifest - shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "darwin-aarch64-app" \ - --latest-base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - - build-macos-x86_64: - name: Build macOS Intel - # Parallel with Windows / macOS Apple Silicon after prepare. - needs: - - prepare-release - runs-on: macos-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Rust setup - uses: dtolnay/rust-toolchain@stable - with: - targets: x86_64-apple-darwin - - - name: Go setup - uses: actions/setup-go@v5 - with: - go-version-file: sidecars/cockpit-cliproxy/go.mod - cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build macOS Intel app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci --target x86_64-apple-darwin - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata - - - name: Stage macOS Intel assets - shell: bash - run: | - set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform macos \ - --mac-arch x64 \ - --assets-dir "target/x86_64-apple-darwin/release/bundle" \ - --output-dir "release-assets" - - - name: Upload macOS Intel assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} macOS Intel assets" - - - name: Build and upload macOS Intel updater manifest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - node scripts/release/build_target_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --targets "darwin-x86_64-app" \ - --output-dir "target-manifests" - gh release upload "v${VERSION}" target-manifests/*.json --clobber - - # Tag URL (not /releases/latest/) — see Windows verify comment. - - name: Verify published macOS Intel updater manifest - shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "darwin-x86_64-app" \ - --latest-base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - - build-macos-universal: - name: Build macOS Universal - # Still sequential after both arch builds (needs arm + x64 artifacts). - needs: - - prepare-release - - build-macos-aarch64 - - build-macos-x86_64 - runs-on: macos-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Rust setup - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin,x86_64-apple-darwin - - - name: Go setup + - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: sidecars/cockpit-cliproxy/go.mod cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - name: Node.js setup + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build macOS Universal app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci --target universal-apple-darwin - - - name: Stage macOS Universal assets - shell: bash - run: | - set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform macos \ - --mac-arch universal \ - --assets-dir "target/universal-apple-darwin/release/bundle" \ - --output-dir "release-assets" - - - name: Upload macOS Universal assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} macOS Universal assets" - - build-linux: - name: Build Linux (${{ matrix.label }}) - # Parallel with Windows / macOS after prepare; matrix arches run in parallel. - needs: - - prepare-release - runs-on: ${{ matrix.platform }} - permissions: - contents: write - strategy: - fail-fast: false - matrix: - include: - - label: "x86_64" - platform: "ubuntu-22.04" - targets: "linux-x86_64-appimage,linux-x86_64-deb,linux-x86_64-rpm" - - label: "aarch64" - platform: "ubuntu-24.04-arm" - targets: "linux-aarch64-appimage,linux-aarch64-deb,linux-aarch64-rpm" - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 + cache: npm - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf pkg-config libsoup-3.0-dev javascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev - sudo apt-get install -y libnm-dev xdg-utils - - - name: Rust setup - uses: dtolnay/rust-toolchain@stable - - - name: Go setup - uses: actions/setup-go@v5 - with: - go-version-file: sidecars/cockpit-cliproxy/go.mod - cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build Linux app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata + run: npm ci - - name: Stage Linux assets + - name: Validate release identity + id: identity shell: bash run: | set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform linux \ - --assets-dir "target/release/bundle" \ - --output-dir "release-assets" - - - name: Upload Linux assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} Linux assets" - - - name: Build and upload Linux updater manifests - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - node scripts/release/build_target_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --targets "${{ matrix.targets }}" \ - --output-dir "target-manifests" - gh release upload "v${VERSION}" target-manifests/*.json --clobber - - # Tag URL (not /releases/latest/) — see Windows verify comment. - - name: Verify published Linux updater manifests - shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "${{ matrix.targets }}" \ - --latest-base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - - finalize-legacy-latest: - name: Finalize legacy latest.json - # Wait for every platform build so latest.json is complete. - needs: - - prepare-release - - build-windows - - build-macos-universal - - build-linux - runs-on: ubuntu-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata - - - name: Download all release assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - mkdir -p release-assets - TAG="v${VERSION}" - downloaded="false" - for i in $(seq 1 30); do - if gh release download "${TAG}" --dir release-assets --pattern "*" --clobber; then - downloaded="true" - break + VERSION="$(node -p "require('./package.json').version")" + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAG_PREFIX="v${VERSION}-jstar." + REVISION="${GITHUB_REF_NAME#"${TAG_PREFIX}"}" + if [[ "${GITHUB_REF_NAME}" != "${TAG_PREFIX}"* ]] || [[ ! "${REVISION}" =~ ^[0-9]+$ ]]; then + echo "Tag ${GITHUB_REF_NAME} must match v${VERSION}-jstar.." + exit 1 fi - echo "Release assets not ready yet (attempt $i/30). Sleeping 10s..." - sleep 10 - done - if [ "${downloaded}" != "true" ]; then - echo "Failed to download release assets from ${TAG} after retries." - exit 1 fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - name: Build complete legacy latest.json - shell: bash - run: | - set -euo pipefail - node scripts/release/build_merged_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --output "latest.json" - - - name: Validate complete legacy latest.json - shell: bash + - name: Validate frontend run: | - set -euo pipefail - jq -e ' - .platforms["darwin-aarch64"] and - .platforms["darwin-x86_64"] and - .platforms["windows-x86_64"] and - .platforms["windows-x86_64-nsis"] and - .platforms["linux-x86_64-appimage"] and - .platforms["linux-x86_64-deb"] and - .platforms["linux-x86_64-rpm"] and - .platforms["linux-aarch64-appimage"] and - .platforms["linux-aarch64-deb"] and - .platforms["linux-aarch64-rpm"] - ' latest.json > /dev/null - jq -r '.platforms | keys[]' latest.json + npm run sync-version + node scripts/check_locales.cjs + npm run typecheck - - name: Publish complete legacy latest.json + - name: Build app env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - gh release upload "v${VERSION}" latest.json --clobber - gh release edit "v${VERSION}" --draft=false --prerelease=false --latest + ./node_modules/.bin/tauri build \ + --ci \ + --config src-tauri/tauri.ci.conf.json \ + --target aarch64-apple-darwin \ + --bundles app 2>&1 | tee build-macos-arm64.log - # End-to-end: user-facing /releases/latest/download (do not use tag URL here). - - name: Verify complete published updater state + - name: Package release asset shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "darwin-aarch64-app,darwin-x86_64-app,windows-x86_64-msi,windows-x86_64-nsis,linux-x86_64-appimage,linux-x86_64-deb,linux-x86_64-rpm,linux-aarch64-appimage,linux-aarch64-deb,linux-aarch64-rpm" \ - --legacy - - upload-checksums: - name: Upload SHA256SUMS - needs: - - prepare-release - - finalize-legacy-latest - runs-on: ubuntu-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Download release assets env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash + VERSION: ${{ steps.identity.outputs.version }} run: | set -euo pipefail + APP_PATH="target/aarch64-apple-darwin/release/bundle/macos/Cockpit Tools.app" + ASSET="Cockpit-Tools_${VERSION}_macos-arm64.app.zip" + test -d "${APP_PATH}" + codesign --force --deep --sign - "${APP_PATH}" + codesign --verify --deep --strict --verbose=2 "${APP_PATH}" mkdir -p release-assets - gh release download "v${VERSION}" --dir release-assets --pattern "*" --clobber - - - name: Generate SHA256SUMS.txt from release assets - shell: bash - run: | - set -euo pipefail - cd release-assets - rm -f SHA256SUMS.txt - find . -type f -print0 \ - | sort -z \ - | while IFS= read -r -d '' file; do - shasum -a 256 "$file" - done \ - | sed 's# \./# #' \ - > ../SHA256SUMS.txt - cd .. - test -s SHA256SUMS.txt - cat SHA256SUMS.txt - - - name: Upload SHA256SUMS.txt to release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - gh release upload "v${VERSION}" SHA256SUMS.txt --clobber - - update-homebrew-cask: - name: Update Homebrew Cask - needs: - - prepare-release - - finalize-legacy-latest - - upload-checksums - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - env: - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout main - uses: actions/checkout@v4 - with: - ref: main + ditto -c -k --sequesterRsrc --keepParent "${APP_PATH}" "release-assets/${ASSET}" + ( + cd release-assets + shasum -a 256 "${ASSET}" > "${ASSET}.sha256" + ) - - name: Download universal DMG from GitHub Releases - shell: bash - run: | - set -euo pipefail - URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}/Cockpit.Tools_${VERSION}_universal.dmg" - echo "Downloading: ${URL}" - for i in $(seq 1 30); do - if curl -fL --retry 3 --retry-delay 1 -o cockpit-tools.dmg "${URL}"; then - exit 0 - fi - echo "Asset not ready yet (attempt $i/30). Sleeping 10s..." - sleep 10 - done - echo "Failed to download release asset after retries." - exit 1 - - - name: Compute sha256 - shell: bash - run: | - set -euo pipefail - SHA256="$(sha256sum cockpit-tools.dmg | awk '{print $1}')" - echo "SHA256=${SHA256}" >> "$GITHUB_ENV" - - - name: Update Cask file - shell: bash - run: | - set -euo pipefail - FILE="Casks/cockpit-tools.rb" - if [ ! -f "${FILE}" ]; then - echo "Missing ${FILE}. Create it first." - exit 1 - fi - - perl -0777 -i -pe \ - 's/version \"[^\"]+\"/version \"'"${VERSION}"'\"/; s/sha256 \"[0-9a-f]{64}\"/sha256 \"'"${SHA256}"'\"/' \ - "${FILE}" - - git diff -- "${FILE}" - - - name: Create pull request - id: create_cask_pr - uses: peter-evans/create-pull-request@v6 + - name: Upload short-lived workflow artifact + uses: actions/upload-artifact@v4 with: - commit-message: "chore(homebrew): update cask for v${{ env.VERSION }}" - title: "chore(homebrew): update cask for v${{ env.VERSION }}" - body: | - Auto-generated by the release workflow. - - - version: ${{ env.VERSION }} - - sha256 (universal.dmg): ${{ env.SHA256 }} - branch: "automation/update-cask-v${{ env.VERSION }}" - base: main - delete-branch: true + name: cockpit-tools-${{ steps.identity.outputs.version }}-macos-arm64 + retention-days: 3 + if-no-files-found: error + path: release-assets/* - - name: Enable auto-merge for cask PR - if: ${{ steps.create_cask_pr.outputs.pull-request-number != '' }} + - name: Publish Fork release + if: startsWith(github.ref, 'refs/tags/') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.identity.outputs.version }} shell: bash run: | set -euo pipefail - PR_NUMBER="${{ steps.create_cask_pr.outputs.pull-request-number }}" - PR_BRANCH="$(gh pr view "${PR_NUMBER}" --json headRefName --jq '.headRefName')" - if [[ "${PR_BRANCH}" != automation/update-cask-v* ]]; then - echo "Unexpected PR branch: ${PR_BRANCH}" - exit 1 + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release upload "${GITHUB_REF_NAME}" release-assets/* --clobber + gh release edit "${GITHUB_REF_NAME}" \ + --title "Cockpit Tools ${VERSION}" + else + gh release create "${GITHUB_REF_NAME}" release-assets/* \ + --title "Cockpit Tools ${VERSION}" \ + --generate-notes \ + --latest fi - gh pr merge "${PR_NUMBER}" --auto --squash --delete-branch diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 66928e4c0..642aebeae 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -9,15 +9,29 @@ fn link_macos_swift_runtime_rpaths() { println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); } +#[cfg(target_os = "macos")] +fn find_named_files(root: &Path, name: &str, matches: &mut Vec) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + find_named_files(&path, name, matches); + } else if path.file_name().and_then(|value| value.to_str()) == Some(name) { + matches.push(path); + } + } +} + #[cfg(target_os = "macos")] fn bridge_swift_rs_xcode_output_layout() { let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is required")); - let (configuration, product_configuration) = - if std::env::var("DEBUG").ok().as_deref() == Some("true") { - ("debug", "Debug") - } else { - ("release", "Release") - }; + let configuration = if std::env::var("DEBUG").ok().as_deref() == Some("true") { + "debug" + } else { + "release" + }; let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").expect("CARGO_CFG_TARGET_ARCH is required"); let arch = match target_arch.as_str() { @@ -25,19 +39,45 @@ fn bridge_swift_rs_xcode_output_layout() { value => value, }; let package_dir = out_dir.join("swift-rs/MacosNativeMenuSwift"); - let actual = package_dir.join(format!("out/Products/{product_configuration}")); let expected = package_dir.join(format!("{arch}-apple-macosx/{configuration}")); - let library = actual.join("libMacosNativeMenuSwift.a"); + let library_name = "libMacosNativeMenuSwift.a"; + if expected.join(library_name).is_file() { + return; + } - if !library.is_file() { + let mut matches = Vec::new(); + find_named_files(&package_dir, library_name, &mut matches); + if matches.is_empty() { panic!( - "swift-rs completed without MacosNativeMenuSwift static library: {}", - library.display() + "swift-rs did not produce {library_name} under {}", + package_dir.display(), ); } - if expected.join("libMacosNativeMenuSwift.a").is_file() { - return; - } + + let xcode_configuration = if configuration == "debug" { + "Debug" + } else { + "Release" + }; + matches.sort(); + let source = matches + .iter() + .find(|path| { + let Some(parent) = path.parent() else { + return false; + }; + parent.file_name().and_then(|value| value.to_str()) == Some(xcode_configuration) + && parent + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + == Some("Products") + }) + .unwrap_or(&matches[0]); + + let staged = package_dir.join(format!(".{library_name}.jstarctl-stage")); + std::fs::copy(source, &staged) + .expect("failed to stage swift-rs static library for compatibility path"); if expected.exists() || expected.is_symlink() { if expected.is_dir() && !expected.is_symlink() { std::fs::remove_dir_all(&expected) @@ -46,14 +86,9 @@ fn bridge_swift_rs_xcode_output_layout() { std::fs::remove_file(&expected).expect("failed to replace stale swift-rs output link"); } } - std::fs::create_dir_all( - expected - .parent() - .expect("swift-rs compatibility path has a parent"), - ) - .expect("failed to create swift-rs compatibility parent"); - std::os::unix::fs::symlink(&actual, &expected) - .expect("failed to link swift-rs compatibility output path"); + std::fs::create_dir_all(&expected).expect("failed to create swift-rs compatibility path"); + std::fs::rename(&staged, expected.join(library_name)) + .expect("failed to install swift-rs static library compatibility copy"); } fn go_target_from_rust_target(target: &str) -> Option<(&'static str, &'static str)> { diff --git a/src-tauri/src/commands/codex.rs b/src-tauri/src/commands/codex.rs index 876d95189..9df9a6102 100644 --- a/src-tauri/src/commands/codex.rs +++ b/src-tauri/src/commands/codex.rs @@ -669,6 +669,29 @@ pub fn save_codex_quick_config( codex_account::save_current_quick_config(model_context_window, auto_compact_token_limit) } +#[tauri::command] +pub fn get_codex_runtime_provider_id() -> String { + config::get_user_config().codex_runtime_provider_id +} + +#[tauri::command] +pub fn save_codex_runtime_provider_id(provider_id: String) -> Result { + let normalized = provider_id.trim().to_ascii_lowercase(); + let mut chars = normalized.chars(); + if !matches!(chars.next(), Some(ch) if ch.is_ascii_alphabetic()) + || !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + return Err( + "Provider ID 必须以英文字母开头,且只能包含小写字母、数字、下划线和连字符".to_string(), + ); + } + config::patch_user_config(|current| { + current.codex_runtime_provider_id = normalized.clone(); + Ok(()) + })?; + Ok(normalized) +} + #[tauri::command] pub fn get_codex_app_speed_config() -> Result { codex_speed::get_app_speed_config() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c754bfd92..e843108be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -741,6 +741,8 @@ pub fn run() { commands::codex::open_codex_config_toml, commands::codex::get_codex_quick_config, commands::codex::save_codex_quick_config, + commands::codex::get_codex_runtime_provider_id, + commands::codex::save_codex_runtime_provider_id, commands::codex::get_codex_app_speed_config, commands::codex::save_codex_app_speed, commands::codex::get_codex_api_service_app_speed_config, diff --git a/src-tauri/src/modules/codex_account.rs b/src-tauri/src/modules/codex_account.rs index a51bfe7a0..36ec539a1 100644 --- a/src-tauri/src/modules/codex_account.rs +++ b/src-tauri/src/modules/codex_account.rs @@ -1324,6 +1324,39 @@ fn set_imagegen_headers(provider_table: &mut toml_edit::Table, images_only_for_c } } +fn configured_runtime_provider_id() -> Option { + #[cfg(test)] + { + None + } + #[cfg(not(test))] + { + let id = crate::modules::config::get_user_config().codex_runtime_provider_id; + sanitize_api_provider_id(&id) + } +} + +fn resolve_api_key_runtime_provider_id( + provider_config: &ApiProviderConfig, + base_url: &str, + configured_id: Option<&str>, +) -> String { + let source_id = provider_config + .provider_id + .as_deref() + .filter(|id| !id.trim().is_empty()) + .unwrap_or(CODEX_RUNTIME_MODEL_PROVIDER_ID); + let is_local_access_loopback = + source_id == CODEX_RUNTIME_MODEL_PROVIDER_ID && is_loopback_http_base_url(Some(base_url)); + if is_local_access_loopback { + return source_id.to_string(); + } + + configured_id + .and_then(sanitize_api_provider_id) + .unwrap_or_else(|| source_id.to_string()) +} + fn write_api_key_provider_to_config_toml( base_dir: &Path, provider_config: &ApiProviderConfig, @@ -1333,6 +1366,27 @@ fn write_api_key_provider_to_config_toml( // true → Codex 使用 auth.json/Keychain OAuth 登录态(绑定 OAuth)。 // false → 纯 API Key,配合 actor 走 bearer 生图。 require_openai_auth: bool, +) -> Result<(), String> { + let configured_provider_id = configured_runtime_provider_id(); + write_api_key_provider_to_config_toml_with_runtime_provider_id( + base_dir, + provider_config, + bearer_token, + supports_websockets, + supports_image_generation, + require_openai_auth, + configured_provider_id.as_deref(), + ) +} + +fn write_api_key_provider_to_config_toml_with_runtime_provider_id( + base_dir: &Path, + provider_config: &ApiProviderConfig, + bearer_token: &str, + supports_websockets: bool, + supports_image_generation: bool, + require_openai_auth: bool, + configured_provider_id: Option<&str>, ) -> Result<(), String> { let config_path = get_config_toml_path(base_dir); let bearer_token = normalize_api_key(bearer_token) @@ -1346,11 +1400,8 @@ fn write_api_key_provider_to_config_toml( .as_deref() .filter(|name| !name.trim().is_empty()) .unwrap_or(CODEX_DEFAULT_RUNTIME_PROVIDER_NAME); - let provider_id = provider_config - .provider_id - .as_deref() - .filter(|id| !id.trim().is_empty()) - .unwrap_or(CODEX_RUNTIME_MODEL_PROVIDER_ID); + let provider_id = + resolve_api_key_runtime_provider_id(provider_config, base_url, configured_provider_id); let existing = fs::read_to_string(&config_path).unwrap_or_default(); let mut doc = if existing.trim().is_empty() { @@ -1366,7 +1417,7 @@ fn write_api_key_provider_to_config_toml( .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_string); - if previous_provider_id.as_deref() != Some(provider_id) { + if previous_provider_id.as_deref() != Some(provider_id.as_str()) { if let Some(previous_provider_id) = previous_provider_id.as_deref() { let managed_provider_ids = collect_managed_api_key_provider_ids(); if managed_provider_ids.contains(previous_provider_id) { @@ -1380,17 +1431,17 @@ fn write_api_key_provider_to_config_toml( } } - doc[CODEX_CONFIG_MODEL_PROVIDER_KEY] = value(provider_id); + doc[CODEX_CONFIG_MODEL_PROVIDER_KEY] = value(provider_id.as_str()); if doc.get(CODEX_CONFIG_MODEL_PROVIDERS_KEY).is_none() { doc[CODEX_CONFIG_MODEL_PROVIDERS_KEY] = toml_edit::table(); } let model_providers = doc[CODEX_CONFIG_MODEL_PROVIDERS_KEY] .as_table_mut() .ok_or("config.toml 中 model_providers 不是合法表结构")?; - if !model_providers.contains_key(provider_id) { - model_providers[provider_id] = toml_edit::table(); + if !model_providers.contains_key(provider_id.as_str()) { + model_providers[provider_id.as_str()] = toml_edit::table(); } - let provider_table = model_providers[provider_id] + let provider_table = model_providers[provider_id.as_str()] .as_table_mut() .ok_or("config.toml 中目标 provider 不是合法表结构")?; provider_table["name"] = value(provider_name); @@ -8585,22 +8636,24 @@ mod tests { load_account_index, looks_like_sub2api_export, now_timestamp, parse_auth_file_last_refresh, parse_codex_account_compat, parse_line_delimited_json_values, read_api_provider_from_config_toml, read_quick_config_from_config_toml, remove_accounts, - resolve_api_provider_config, save_account, save_account_index, - should_accept_authority_snapshot, sync_account_from_auth_dir, + resolve_api_key_runtime_provider_id, resolve_api_provider_config, save_account, + save_account_index, should_accept_authority_snapshot, sync_account_from_auth_dir, sync_api_key_account_from_local_state, sync_api_key_provider_accounts, sync_managed_projection_from_auth_dir, try_parse_pending_oauth_delimited_line, upsert_account, upsert_account_for_reauth, upsert_account_from_access_token, upsert_account_from_access_token_with_hints, upsert_account_from_auth_tokens, upsert_api_key_account, validate_api_key_credentials, write_account_bundle_to_dir, - write_api_key_provider_to_config_toml, write_api_provider_to_config_toml, - write_managed_projection_to_dir, write_quick_config_to_config_toml, ApiProviderConfig, - CodexAccessTokenImportHints, CodexAccountGroupRecord, CodexAccountIndex, - CodexAccountSummary, CodexAuthFile, CodexAuthTokens, CodexGroupQuotaRefreshPolicy, - CodexJsonImportCandidate, LocalCodexOAuthSnapshot, CODEX_ACCOUNT_DETAIL_SCHEMA_VERSION, + write_api_key_provider_to_config_toml, + write_api_key_provider_to_config_toml_with_runtime_provider_id, + write_api_provider_to_config_toml, write_managed_projection_to_dir, + write_quick_config_to_config_toml, ApiProviderConfig, CodexAccessTokenImportHints, + CodexAccountGroupRecord, CodexAccountIndex, CodexAccountSummary, CodexAuthFile, + CodexAuthTokens, CodexGroupQuotaRefreshPolicy, CodexJsonImportCandidate, + LocalCodexOAuthSnapshot, CODEX_ACCOUNT_DETAIL_SCHEMA_VERSION, CODEX_AUTHORIZATION_STATUS_PENDING, CODEX_AUTO_COMPACT_DEFAULT_LIMIT, CODEX_CONTEXT_WINDOW_1M_VALUE, CODEX_DISABLE_HOSTED_IMAGE_GENERATION_HEADER, CODEX_DISABLE_HOSTED_IMAGE_GENERATION_HEADER_VALUE, CODEX_IMAGEGEN_ACTOR_HEADER, - CODEX_IMAGEGEN_ACTOR_HEADER_VALUE, CODEX_IMAGE_MODEL_ID, + CODEX_IMAGEGEN_ACTOR_HEADER_VALUE, CODEX_IMAGE_MODEL_ID, CODEX_RUNTIME_MODEL_PROVIDER_ID, }; use crate::models::codex::{CodexAccount, CodexApiProviderMode, CodexTokens}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; @@ -10827,6 +10880,107 @@ multi_agent = true fs::remove_dir_all(&base_dir).expect("cleanup temp dir"); } + #[test] + fn runtime_provider_id_projects_different_upstreams_into_one_stable_slot() { + let first = resolve_api_provider_config( + Some("https://first.example.com/v1"), + Some(CodexApiProviderMode::Custom), + Some("cmp_first"), + Some("First"), + ) + .expect("resolve first provider"); + let second = resolve_api_provider_config( + Some("https://second.example.com/v1"), + Some(CodexApiProviderMode::Custom), + Some("cmp_second"), + Some("Second"), + ) + .expect("resolve second provider"); + + assert_eq!( + resolve_api_key_runtime_provider_id( + &first, + "https://first.example.com/v1", + Some("main"), + ), + "main" + ); + assert_eq!( + resolve_api_key_runtime_provider_id( + &second, + "https://second.example.com/v1", + Some("main"), + ), + "main" + ); + } + + #[test] + fn runtime_provider_id_keeps_real_config_projection_stable_across_switches() { + let base_dir = make_temp_dir("codex-runtime-provider-switch-test"); + let first = resolve_api_provider_config( + Some("https://first.example.com/v1"), + Some(CodexApiProviderMode::Custom), + Some("cmp_first"), + Some("First"), + ) + .expect("resolve first provider"); + let second = resolve_api_provider_config( + Some("https://second.example.com/v1"), + Some(CodexApiProviderMode::Custom), + Some("cmp_second"), + Some("Second"), + ) + .expect("resolve second provider"); + + write_api_key_provider_to_config_toml_with_runtime_provider_id( + &base_dir, + &first, + "sk-first", + false, + false, + true, + Some("main"), + ) + .expect("write first provider"); + write_api_key_provider_to_config_toml_with_runtime_provider_id( + &base_dir, + &second, + "sk-second", + false, + false, + true, + Some("main"), + ) + .expect("write second provider"); + + let content = fs::read_to_string(base_dir.join("config.toml")).expect("read config"); + assert!(content.contains("model_provider = \"main\"")); + assert!(content.contains("[model_providers.main]")); + assert!(content.contains("name = \"Second\"")); + assert!(content.contains("base_url = \"https://second.example.com/v1\"")); + assert!(!content.contains("model_providers.cmp_first")); + assert!(!content.contains("model_providers.cmp_second")); + + fs::remove_dir_all(&base_dir).expect("cleanup temp dir"); + } + + #[test] + fn runtime_provider_id_setting_does_not_replace_local_gateway_identity() { + let local = resolve_api_provider_config( + Some("http://127.0.0.1:1455/v1"), + Some(CodexApiProviderMode::Custom), + Some(CODEX_RUNTIME_MODEL_PROVIDER_ID), + Some("Cockpit Local Access"), + ) + .expect("resolve local provider"); + + assert_eq!( + resolve_api_key_runtime_provider_id(&local, "http://127.0.0.1:1455/v1", Some("main")), + CODEX_RUNTIME_MODEL_PROVIDER_ID + ); + } + #[test] fn api_key_provider_projection_preserves_unmanaged_config_sections() { let base_dir = make_temp_dir("codex-api-key-config-preserve-sections-test"); diff --git a/src-tauri/src/modules/config.rs b/src-tauri/src/modules/config.rs index 534f61257..6d95e6754 100644 --- a/src-tauri/src/modules/config.rs +++ b/src-tauri/src/modules/config.rs @@ -107,6 +107,9 @@ pub struct UserConfig { /// 是否启用 Codex 客户端中的 API 服务额度显示注入 #[serde(default = "default_codex_app_ui_injection_enabled")] pub codex_app_ui_injection_enabled: bool, + /// API Key 供应商切换时写入 Codex config.toml 的稳定 provider ID + #[serde(default = "default_codex_runtime_provider_id")] + pub codex_runtime_provider_id: String, /// Codex WSL 配置目录 (Windows Only) #[serde(default = "default_codex_wsl_config_dir")] pub codex_wsl_config_dir: String, @@ -668,6 +671,9 @@ fn default_codex_sync_wsl() -> bool { fn default_codex_app_ui_injection_enabled() -> bool { true } +fn default_codex_runtime_provider_id() -> String { + "main".to_string() +} fn default_codex_wsl_config_dir() -> String { String::new() } @@ -1104,6 +1110,7 @@ impl Default for UserConfig { codex_auto_refresh_minutes: default_codex_auto_refresh(), codex_sync_wsl: default_codex_sync_wsl(), codex_app_ui_injection_enabled: default_codex_app_ui_injection_enabled(), + codex_runtime_provider_id: default_codex_runtime_provider_id(), codex_wsl_config_dir: default_codex_wsl_config_dir(), zed_auto_refresh_minutes: default_zed_auto_refresh(), ghcp_auto_refresh_minutes: default_ghcp_auto_refresh(), @@ -2445,6 +2452,16 @@ mod tests { assert!(!migrated_cfg.grok_sync_official_auth_on_switch); } + #[test] + fn codex_runtime_provider_id_defaults_to_main() { + let default_cfg = UserConfig::default(); + assert_eq!(default_cfg.codex_runtime_provider_id, "main"); + + let migrated_cfg: UserConfig = + serde_json::from_value(serde_json::json!({})).expect("旧配置反序列化应成功"); + assert_eq!(migrated_cfg.codex_runtime_provider_id, "main"); + } + #[test] fn codex_api_service_quota_display_defaults_to_enabled() { let default_cfg = UserConfig::default(); diff --git a/src/components/codex/CodexQuickConfigCard.tsx b/src/components/codex/CodexQuickConfigCard.tsx index 82e7f9f14..d4a378563 100644 --- a/src/components/codex/CodexQuickConfigCard.tsx +++ b/src/components/codex/CodexQuickConfigCard.tsx @@ -4,8 +4,10 @@ import { ChevronLeft, CircleAlert, FolderOpen, Save, X } from 'lucide-react'; import { getCodexConfigTomlPath, getCodexQuickConfig, + getCodexRuntimeProviderId, openCodexConfigToml, saveCodexQuickConfig, + saveCodexRuntimeProviderId, } from '../../services/codexService'; import { useEscClose } from '../../hooks/useEscClose'; import type { CodexQuickConfig } from '../../types/codex'; @@ -77,6 +79,7 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) { const [autoCompactLimitInput, setAutoCompactLimitInput] = useState( String(DEFAULT_AUTO_COMPACT_TOKEN_LIMIT), ); + const [runtimeProviderIdInput, setRuntimeProviderIdInput] = useState('main'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [opening, setOpening] = useState(false); @@ -104,12 +107,14 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) { setLoading(true); setError(null); try { - const [path, config] = await Promise.all([ + const [path, config, runtimeProviderId] = await Promise.all([ getCodexConfigTomlPath(), getCodexQuickConfig(), + getCodexRuntimeProviderId(), ]); setConfigPath(path); applyLoadedConfig(config); + setRuntimeProviderIdInput(runtimeProviderId); } catch (err) { setError( t('codex.modelProviders.quickConfig.loadFailed', { @@ -209,7 +214,15 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) { ); }, [isCustomPreset, parsedAutoCompactLimit, t]); - const validationError = contextWindowError ?? compactLimitError; + const runtimeProviderIdError = useMemo(() => { + if (/^[a-z][a-z0-9_-]*$/.test(runtimeProviderIdInput.trim())) return null; + return t( + 'codex.modelProviders.quickConfig.validation.runtimeProviderIdInvalid', + 'Provider ID 必须以小写英文字母开头,且只能包含小写字母、数字、下划线和连字符', + ); + }, [runtimeProviderIdInput, t]); + + const validationError = contextWindowError ?? compactLimitError ?? runtimeProviderIdError; const targetConfig = useMemo(() => { if (selectedPresetId === 'custom') { @@ -303,7 +316,11 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) { targetConfig.modelContextWindow ?? undefined, targetConfig.autoCompactTokenLimit ?? undefined, ); + const savedRuntimeProviderId = await saveCodexRuntimeProviderId( + runtimeProviderIdInput, + ); applyLoadedConfig(saved); + setRuntimeProviderIdInput(savedRuntimeProviderId); setNotice( t( 'codex.modelProviders.quickConfig.saveSuccess', @@ -382,6 +399,39 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) {
+
+ + { + setNotice(null); + setError(null); + setRuntimeProviderIdInput(event.target.value); + }} + disabled={saving} + /> +

+ {t( + 'codex.modelProviders.quickConfig.runtimeProviderIdHint', + '切换不同供应商时保持这个 ID 不变,避免 Codex 任务按供应商拆分。保存后在下次切换或启用供应商时生效。', + )} +

+ {runtimeProviderIdError && ( +
+ + {runtimeProviderIdError} +
+ )} +