diff --git a/.github/actions/setup-node-cache/action.yml b/.github/actions/setup-node-cache/action.yml new file mode 100644 index 00000000..61933ea4 --- /dev/null +++ b/.github/actions/setup-node-cache/action.yml @@ -0,0 +1,51 @@ +name: Setup Node.js with cache +description: Sets up Node.js, caches node_modules and npm, and installs dependencies if needed. + +runs: + using: composite + steps: + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Compute node modules cache key + id: nodeModulesCacheKey + shell: bash + run: echo "value=$(sha256sum package-lock.json | cut -d ' ' -f 1)" >> $GITHUB_OUTPUT + + - name: Cache node modules + id: cacheNodeModules + uses: actions/cache@v5 + with: + path: "**/node_modules" + key: ${{ runner.os }}-cacheNodeModules20-${{ steps.nodeModulesCacheKey.outputs.value }} + restore-keys: ${{ runner.os }}-cacheNodeModules20- + + - name: Get npm cache directory path + id: npmCacheDirPath + if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} + shell: bash + run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT + + - name: Cache npm directory + if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} + uses: actions/cache@v5 + with: + path: ${{ steps.npmCacheDirPath.outputs.dir }} + key: ${{ runner.os }}-npmCacheDir-${{ steps.nodeModulesCacheKey.outputs.value }} + restore-keys: ${{ runner.os }}-npmCacheDir- + + - name: Install system dependencies + if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} + shell: bash + run: | + sudo apt update + sudo apt install -y libxkbfile-dev pkg-config libkrb5-dev libxss1 + + - name: Install npm dependencies + if: ${{ steps.cacheNodeModules.outputs.cache-hit != 'true' }} + shell: bash + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + run: npm ci --ignore-scripts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a00aa675 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,176 @@ +# CI pipeline for pull requests targeting main. +# Runs four parallel jobs: +# - frontend-lint: ESLint, Stylelint, and hygiene checks +# - frontend-compile: TypeScript compilation and layer validation +# - backend-lint: Clippy for the Tauri/Rust backend +# - backend-format: rustfmt check for the Tauri/Rust backend +# +# Uses dorny/paths-filter so that each job runs only when relevant files change, +# while remaining compatible with branch protection required status checks. +name: CI + +on: + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: read + outputs: + frontend: ${{ steps.filter.outputs.frontend }} + backend: ${{ steps.filter.outputs.backend }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + frontend: + - 'src/**' + - 'extensions/**' + - 'build/**' + - 'test/**' + - 'package.json' + - 'package-lock.json' + - '.nvmrc' + - 'eslint.config.js' + - '.eslint-ignore' + - '.eslint-plugin-local/**' + - 'tsfmt.json' + - '.editorconfig' + - '.github/actions/**' + - '.github/workflows/ci.yml' + backend: + - 'src-tauri/**' + - '.github/workflows/ci.yml' + + frontend-lint: + name: Frontend Lint + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-node-cache + + - name: ESLint + run: npm run eslint + + - name: Stylelint + run: npm run stylelint + + - name: Hygiene + run: npm run hygiene + + frontend-compile: + name: Frontend Compile Check + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-node-cache + + - name: TypeScript compile check + run: npm run compile-check-ts-native + + - name: Valid layers check + run: npm run valid-layers-check + + backend-lint: + name: Backend Lint + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # Create empty out/ directory so tauri::generate_context!() can resolve frontendDist: "../out" + - name: Create frontend dist stub + run: mkdir -p out + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Clippy + working-directory: src-tauri + run: cargo clippy -- -D warnings + + backend-format: + name: Backend Format + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + src-tauri + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check format + working-directory: src-tauri + run: cargo fmt --check + + # Gate job: aggregates all job results into a single required status check. + # This job always runs (even when other jobs are skipped by paths-filter), + # making it safe to use as a required status check in branch protection. + ci-gate: + name: CI Gate + if: ${{ always() }} + needs: [frontend-lint, frontend-compile, backend-lint, backend-format] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check job results + run: | + results=("${{ needs.frontend-lint.result }}" "${{ needs.frontend-compile.result }}" "${{ needs.backend-lint.result }}" "${{ needs.backend-format.result }}") + for result in "${results[@]}"; do + if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then + echo "::error::One or more CI jobs failed or were cancelled." + exit 1 + fi + done + echo "All CI jobs passed or were skipped."