name: CI on: push: branches: [master, main] pull_request: branches: [master, main] schedule: - cron: '31 6 * * 1' permissions: contents: read concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 RUSTFLAGS: -Dwarnings jobs: changes: name: Change detection runs-on: ubuntu-latest outputs: heavy: ${{ steps.detect.outputs.heavy }} workflow: ${{ steps.detect.outputs.workflow }} mobile: ${{ steps.detect.outputs.mobile }} actions: ${{ steps.detect.outputs.actions }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Detect executable changes id: detect shell: bash env: EVENT_NAME: ${{ github.event_name }} BASE_REF: ${{ github.base_ref }} BEFORE_SHA: ${{ github.event.before }} run: | set -euo pipefail if [[ "${EVENT_NAME}" == "schedule" || "${EVENT_NAME}" == "workflow_dispatch" ]]; then echo "heavy=true" >> "${GITHUB_OUTPUT}" echo "workflow=true" >> "${GITHUB_OUTPUT}" echo "mobile=true" >> "${GITHUB_OUTPUT}" echo "actions=true" >> "${GITHUB_OUTPUT}" exit 0 fi base="" if [[ "${EVENT_NAME}" == "pull_request" && -n "${BASE_REF}" ]]; then git fetch --no-tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}" --depth=1 base="origin/${BASE_REF}" elif [[ -n "${BEFORE_SHA}" && "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]]; then base="${BEFORE_SHA}" fi if [[ -z "${base}" ]]; then echo "heavy=true" >> "${GITHUB_OUTPUT}" echo "workflow=true" >> "${GITHUB_OUTPUT}" echo "mobile=true" >> "${GITHUB_OUTPUT}" echo "actions=true" >> "${GITHUB_OUTPUT}" exit 0 fi mapfile -t changed < <(git diff --name-only "${base}" "${GITHUB_SHA}" | sort) heavy=false workflow=false mobile=false actions=false for path in "${changed[@]}"; do # Heavy classification. ORDER MATTERS: must-stay-heavy inputs are # matched BEFORE any light entry so a script that only a # heavy-gated job exercises can never be misclassified as light. # Anything unrecognized falls through to the default-heavy `*)` # arm (fail-safe default-heavy). Light-classified scripts below # are either never run by CI (v0867-setup-qa.sh) or exercised by # ALWAYS-on jobs/steps that run regardless of `heavy` # (check-versions.sh / check-ohos-deps.sh via Version drift, # check-coauthor-trailers.py via Lint), so no coverage is lost. case "${path}" in scripts/release/npm-wrapper-smoke.js|scripts/mobile-smoke.sh|scripts/check-provider-registry.py) heavy=true ;; docs/*|*.md|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/v0867-setup-qa.sh|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/check-coauthor-trailers.py) ;; *) heavy=true ;; esac case "${path}" in crates/workflow/*|workflows/rlm_cache_change.star|.github/workflows/ci.yml) workflow=true ;; esac # Mobile runtime surface: the `codewhale-tui serve --mobile` # HTTP/SSE stack that scripts/mobile-smoke.sh exercises. Pull # requests run the smoke only when one of these changes; every # push to main still runs it unconditionally as the pre-release # safety net for anything this filter misses. case "${path}" in crates/app-server/*|crates/tui/src/runtime_api*|crates/tui/src/runtime_mobile.html|crates/tui/src/runtime_threads*|crates/tui/src/main.rs|scripts/mobile-smoke.sh|.github/workflows/ci.yml|Cargo.lock|Cargo.toml) mobile=true ;; esac case "${path}" in .github/workflows/*|.github/actionlint.yml) actions=true ;; esac done echo "heavy=${heavy}" >> "${GITHUB_OUTPUT}" echo "workflow=${workflow}" >> "${GITHUB_OUTPUT}" echo "mobile=${mobile}" >> "${GITHUB_OUTPUT}" echo "actions=${actions}" >> "${GITHUB_OUTPUT}" echo "Heavy Rust CI required: ${heavy}" echo "Workflow RLM cache CI required: ${workflow}" echo "Mobile runtime smoke required (PRs): ${mobile}" echo "Workflow lint required: ${actions}" printf 'Changed files:\n' printf ' %s\n' "${changed[@]}" versions: name: Version drift runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: actions/setup-node@v6 with: node-version: 20 - name: Check version drift run: ./scripts/release/check-versions.sh - name: Check OHOS dependency graph run: ./scripts/release/check-ohos-deps.sh lint: name: Lint needs: changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: dtolnay/rust-toolchain@master if: needs.changes.outputs.heavy == 'true' with: toolchain: stable components: rustfmt, clippy - uses: mozilla-actions/sccache-action@v0.0.10 id: sccache # Cache bootstrap failures (e.g. GitHub 504s fetching the sccache # binary) degrade to an uncached build instead of failing product CI. continue-on-error: true if: needs.changes.outputs.heavy == 'true' - name: Enable sccache if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success' shell: bash run: | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" - name: Install Linux system dependencies if: needs.changes.outputs.heavy == 'true' run: | for i in 1 2 3 4 5; do sudo apt-get update && break echo "apt-get update failed (attempt $i); retrying in 15s" sleep 15 done sudo apt-get install -y libdbus-1-dev pkg-config - uses: Swatinem/rust-cache@v2 if: needs.changes.outputs.heavy == 'true' with: cache-bin: false # PRs restore the cache seeded by main but skip the expensive # post-job save; sccache covers PR-specific compilation deltas. save-if: ${{ github.ref == 'refs/heads/main' }} - name: Check formatting if: needs.changes.outputs.heavy == 'true' run: cargo fmt --all -- --check - name: Run clippy if: needs.changes.outputs.heavy == 'true' run: | cargo clippy --workspace --all-features --locked -- \ -D warnings \ -A clippy::uninlined_format_args \ -A clippy::too_many_arguments \ -A clippy::unnecessary_map_or \ -A clippy::collapsible_if \ -A clippy::assertions_on_constants - name: sccache stats if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success' continue-on-error: true shell: bash run: sccache --show-stats - name: Check provider registry drift if: needs.changes.outputs.heavy == 'true' run: python3 scripts/check-provider-registry.py - name: Check README translations stay in sync if: github.event_name != 'schedule' run: python3 scripts/check-readme-translations.py - name: Check harvested contributor credit if: github.event_name != 'schedule' shell: bash run: | if [[ "${{ github.event_name }}" == "pull_request" ]]; then git fetch --no-tags origin "${{ github.base_ref }}" RANGE="origin/${{ github.base_ref }}..HEAD" elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then RANGE="${{ github.event.before }}..${{ github.sha }}" else RANGE="HEAD~1..HEAD" fi python3 scripts/check-coauthor-trailers.py \ --author-map .github/AUTHOR_MAP \ --range "$RANGE" \ --check-authors - name: Skip Rust lint for light change if: needs.changes.outputs.heavy != 'true' run: echo "No executable Rust changes detected; preserving required Lint context." - name: Linux clippy location if: needs.changes.outputs.heavy == 'true' run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches." workflow-rlm-cache: name: Workflow RLM cache needs: changes if: needs.changes.outputs.workflow == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: mozilla-actions/sccache-action@v0.0.10 id: sccache continue-on-error: true - name: Enable sccache if: steps.sccache.outcome == 'success' shell: bash run: | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" - uses: Swatinem/rust-cache@v2 with: cache-bin: false save-if: ${{ github.ref == 'refs/heads/main' }} - name: Run RLM cache workflow mock/replay tests run: cargo test -p codewhale-workflow --locked rlm_cache_change test: name: Test needs: changes # Required contexts "Test (ubuntu-latest)" / "Test (macos-latest)" / # "Test (windows-latest)" derive from job name + matrix.os and are # independent of runs-on. For light changes the macOS/Windows legs only # echo a skip line, so run them on ubuntu instead of queueing for scarce # macOS/Windows runners. Heavy changes use the real matrix OS as before. # The ternary is safe: matrix.os is always a non-empty literal, so # runs-on can never evaluate to empty. runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }} strategy: matrix: # Linux workspace tests moved to CNB; GitHub keeps the platform # coverage CNB cannot provide. os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Skip tests for light change if: needs.changes.outputs.heavy != 'true' run: echo "No executable Rust changes detected; preserving required Test context." - uses: actions/checkout@v7 if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' - uses: dtolnay/rust-toolchain@stable if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' - uses: mozilla-actions/sccache-action@v0.0.10 id: sccache continue-on-error: true if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' - name: Enable sccache if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success' shell: bash run: | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" - uses: Swatinem/rust-cache@v2 if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' with: cache-bin: false save-if: ${{ github.ref == 'refs/heads/main' }} - name: Run tests if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' run: cargo test --workspace --all-features --locked - name: Lockfile drift guard if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' run: git diff --exit-code -- Cargo.lock - name: Run Offline Eval Harness # The eval harness is OS-independent prompt/composition checking; # running it once (on the faster macOS leg, warm from the test build) # instead of once per desktop OS keeps the coverage while taking # ~2min off the Windows critical path. if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' run: cargo run -p codewhale-tui --all-features -- eval - name: sccache stats if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success' continue-on-error: true shell: bash run: sccache --show-stats - name: Linux test location if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' run: echo "Linux workspace tests run on CNB for mirrored first-party branches." npm-wrapper-smoke: name: npm wrapper smoke needs: changes if: github.event_name != 'schedule' # Same ternary rationale as the Test job: light legs only echo, so keep # them off macOS/Windows runners. On pull_request the matrix is # ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)" # context is unaffected. runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }} strategy: matrix: os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }} steps: - name: Skip npm wrapper smoke for light change if: needs.changes.outputs.heavy != 'true' run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context." - uses: actions/checkout@v7 if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' - uses: dtolnay/rust-toolchain@stable if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' - uses: mozilla-actions/sccache-action@v0.0.10 id: sccache continue-on-error: true if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' - name: Enable sccache if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success' shell: bash run: | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" - uses: actions/setup-node@v6 if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' with: node-version: 20 - uses: Swatinem/rust-cache@v2 if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' with: cache-bin: false save-if: ${{ github.ref == 'refs/heads/main' }} - name: Build wrapper binaries if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' # The smoke validates wrapper install/delegation plumbing, not # codegen quality, so skip fat LTO + codegen-units=1 for a much # cheaper release build. Shipped binaries keep the real profile via # the Release workflow. env: CARGO_PROFILE_RELEASE_LTO: 'off' CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' run: cargo build --release --locked -p codewhale-cli -p codewhale-tui - name: Smoke wrapper install and delegated entrypoints if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' run: node scripts/release/npm-wrapper-smoke.js - name: sccache stats if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success' continue-on-error: true shell: bash run: sccache --show-stats - name: Linux smoke location if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' run: echo "Linux npm wrapper smoke runs on CNB for mirrored first-party branches." mobile-smoke: name: Mobile runtime smoke needs: changes # Not a required PR context. Pull requests run it only when the mobile # runtime surface changed (see the `mobile` filter above); every push to # main runs it unconditionally as the pre-release safety net. if: >- github.event_name != 'schedule' && needs.changes.outputs.heavy == 'true' && (github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true') runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: mozilla-actions/sccache-action@v0.0.10 id: sccache continue-on-error: true - name: Enable sccache if: steps.sccache.outcome == 'success' shell: bash run: | echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}" echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}" echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}" - name: Install Linux system dependencies run: | for i in 1 2 3 4 5; do sudo apt-get update && break echo "apt-get update failed (attempt $i); retrying in 15s" sleep 15 done sudo apt-get install -y libdbus-1-dev pkg-config - uses: Swatinem/rust-cache@v2 with: cache-bin: false save-if: ${{ github.ref == 'refs/heads/main' }} - name: Run mobile smoke tests # The smoke exercises HTTP/SSE runtime behaviour, not codegen # quality; skipping fat LTO + codegen-units=1 cuts the in-script # release build from ~12min to a fraction of that. env: CARGO_PROFILE_RELEASE_LTO: 'off' CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' run: ./scripts/mobile-smoke.sh - name: sccache stats if: steps.sccache.outcome == 'success' continue-on-error: true shell: bash run: sccache --show-stats actionlint: name: Workflow lint needs: changes if: needs.changes.outputs.actions == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Run actionlint uses: docker://rhysd/actionlint:1.7.7 with: # SC2129 (grouped redirects) is style-only and endemic to the # existing GITHUB_ENV/GITHUB_OUTPUT append pattern; SC2221/SC2222 # flag the long-standing `*.md` glob shadowing the PR-template # entry in change detection, which is intentional. args: -color -ignore SC2129 -ignore SC2221 -ignore SC2222 # Check documentation builds without warnings docs: name: Documentation if: github.event_name == 'schedule' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - name: Install Linux system dependencies if: runner.os == 'Linux' run: | for i in 1 2 3 4 5; do sudo apt-get update && break echo "apt-get update failed (attempt $i); retrying in 15s" sleep 15 done sudo apt-get install -y libdbus-1-dev pkg-config - uses: Swatinem/rust-cache@v2 with: cache-bin: false - name: Build docs run: cargo doc --workspace --no-deps env: RUSTDOCFLAGS: -Dwarnings