chore: release v5.2.1: documentation audit fixes #667
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| paths-ignore: | |
| - 'LICENSE' | |
| - '.gitignore' | |
| - '.beads/**' | |
| - 'screenshots/**' | |
| pull_request: | |
| branches: [main] | |
| paths-ignore: | |
| - 'LICENSE' | |
| - '.gitignore' | |
| - '.beads/**' | |
| - 'screenshots/**' | |
| workflow_dispatch: | |
| # Workflow-wide GITHUB_TOKEN baseline: `contents: read` is the minimum | |
| # `actions/checkout` needs and implicitly denies every other scope. | |
| # Jobs that need additional permissions opt in explicitly -- `build` | |
| # and `rust` below grant `id-token: write` for Codecov OIDC tokenless | |
| # uploads (no shared secret, works on fork PRs). We deliberately do | |
| # NOT set `read-all` here: extra read scopes would authorize future | |
| # steps to read data this workflow has no business touching. | |
| permissions: | |
| contents: read | |
| # Cancel superseded matrix runs on PR branches (typically a force-push | |
| # to an open PR). Post-merge runs on `main` always complete: the merge | |
| # SHA's tree can differ from any PR head, and cancelling them would | |
| # erode the "main is green" guarantee that branch protection relies | |
| # on. Group is keyed on workflow + ref so push and pull_request runs | |
| # land in different groups (`refs/heads/main` vs `refs/pull/N/merge`). | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| changes: | |
| # Cheap path-filter pass that classifies the diff and gates the heavy | |
| # Dart and Rust jobs below. The trigger-level `paths-ignore` only | |
| # filters truly-irrelevant assets (LICENSE, top-level .gitignore, | |
| # screenshots, beads issue snapshots). Markdown is intentionally NOT | |
| # in `paths-ignore` so doc-only PRs still trigger the workflow and | |
| # required status checks resolve (skipped jobs count as passing for | |
| # branch protection); this job classifies the diff so doc-only PRs | |
| # skip the heavy build/rust matrices instead. | |
| name: Detect changed paths | |
| runs-on: ubuntu-latest | |
| outputs: | |
| rust: ${{ steps.filter.outputs.rust }} | |
| bindings: ${{ steps.filter.outputs.bindings }} | |
| dart: ${{ steps.filter.outputs.dart }} | |
| ci: ${{ steps.filter.outputs.ci }} | |
| docs: ${{ steps.filter.outputs.docs }} | |
| hooks: ${{ steps.filter.outputs.hooks }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 | |
| - name: Filter changed paths | |
| id: filter | |
| uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 | |
| with: | |
| filters: | | |
| rust: | |
| - 'rust/**' | |
| - 'hook/**' | |
| - 'flutter_rust_bridge.yaml' | |
| - 'pubspec.yaml' | |
| bindings: | |
| - 'lib/src/ffi/**' | |
| - 'tool/check_bindings.dart' | |
| dart: | |
| - 'lib/**' | |
| - 'test/**' | |
| # The `build` job is the only one that runs `flutter analyze` | |
| # on the example app, so example-only diffs must set `dart` to | |
| # avoid passing the `Dart tests gate` by skip (the signals v7 | |
| # `Watch` break in #142 reached `main` exactly this way). | |
| - 'example/**' | |
| - 'pubspec.yaml' | |
| - 'analysis_options.yaml' | |
| - 'sonar-project.properties' | |
| ci: | |
| - '.github/workflows/**' | |
| docs: | |
| - '**.md' | |
| - 'tool/check_doc_snippets.dart' | |
| hooks: | |
| - 'tool/hooks/**' | |
| build: | |
| # Matrix exercises both the declared SDK floor (3.38.10 -- oldest stable | |
| # satisfying `flutter: ^3.38.0` in pubspec.yaml, ships Dart 3.10.9) and | |
| # the latest `stable` channel (matches .fvmrc `"flutter": "stable"`). | |
| # Format check runs only on the stable leg because `dart format` output | |
| # can shift between Dart minors and we don't want spurious failures on | |
| # the floor. | |
| # | |
| # Skipped (whole matrix) when the diff is doc-only: `**.md` is no | |
| # longer in trigger-level `paths-ignore` (so required status checks | |
| # can resolve on doc PRs), but there's nothing for `dart analyze` or | |
| # `flutter test` to validate when only Markdown moved. Skipped jobs | |
| # count as passing for branch protection, so the six required | |
| # checks (`Detect changed paths`, `Dart tests gate`, `Verify FRB | |
| # bindings are in sync`, `Rust build + tests + coverage`, `Hooks | |
| # shell-syntax check`, `Hooks behaviour check`) all report green. | |
| name: Format / analyze / Dart tests (Flutter ${{ matrix.flutter }}) | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: | | |
| github.event_name == 'workflow_dispatch' || | |
| needs.changes.outputs.dart == 'true' || | |
| needs.changes.outputs.rust == 'true' || | |
| needs.changes.outputs.bindings == 'true' || | |
| needs.changes.outputs.ci == 'true' | |
| # `id-token: write` is required for the Codecov OIDC tokenless | |
| # upload below. Scoped to this job rather than workflow-wide so the | |
| # `rust-bridge-sync` job (which does not upload coverage) keeps the | |
| # read-only default. Codecov v6+ on public repos validates the | |
| # GitHub OIDC JWT (repository / repository_owner claims), so a | |
| # stolen token can only upload junk coverage to this repo's own | |
| # Codecov project; no shared secret is exposed to fork PRs. | |
| permissions: | |
| contents: read | |
| id-token: write | |
| pull-requests: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| flutter: ['3.38.10', 'stable'] | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 | |
| # `subosito/flutter-action@v2` does not accept channel names as a | |
| # `flutter-version` value, so the `stable` matrix entry is mapped to | |
| # `any` (the action's documented "latest of channel" sentinel) while | |
| # the floor leg passes its semver through unchanged. Keeping the | |
| # matrix label as `stable` preserves the human-readable status-check | |
| # name (`Format / analyze / Dart tests (Flutter stable)`). | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 | |
| with: | |
| flutter-version: ${{ matrix.flutter == 'stable' && 'any' || matrix.flutter }} | |
| channel: 'stable' | |
| cache: true | |
| # pubspec.lock is gitignored (standard for libraries), so the | |
| # cache key falls back to pubspec.yaml content hash with a | |
| # matrix.flutter discriminator. restore-keys allows a partial | |
| # restore when only the pubspec changed: pub reuses the bulk of | |
| # the existing cache and only fetches the deltas. | |
| - name: Cache pub | |
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 | |
| with: | |
| path: ~/.pub-cache | |
| key: pub-${{ runner.os }}-${{ matrix.flutter }}-${{ hashFiles('**/pubspec.yaml') }} | |
| restore-keys: | | |
| pub-${{ runner.os }}-${{ matrix.flutter }}- | |
| - name: Resolve package dependencies | |
| run: flutter pub get | |
| - name: Verify formatting | |
| if: matrix.flutter == 'stable' | |
| run: dart format --output=none --set-exit-if-changed . | |
| - name: Analyze package | |
| run: dart analyze . | |
| # `--coverage` writes coverage/lcov.info. Generated matrix-wide so | |
| # coverage gaps caused by floor-only or stable-only code paths surface | |
| # on both legs; uploaded stable-only below to avoid double-counting | |
| # line coverage in downstream reports. | |
| - name: Run Dart tests (mock-mode FRB smoke) | |
| run: flutter test --coverage | |
| # Coverage upload gated on (a) the stable Flutter leg and (b) | |
| # whether the diff actually touched Dart sources, the workflow | |
| # itself, or was a manual rerun. Stops a Rust-only PR from | |
| # republishing the same Dart coverage twice (once per matrix | |
| # leg, once per merge). | |
| - name: Upload Dart coverage artifact | |
| if: | | |
| matrix.flutter == 'stable' && ( | |
| github.event_name == 'workflow_dispatch' || | |
| needs.changes.outputs.dart == 'true' || | |
| needs.changes.outputs.ci == 'true' | |
| ) | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: coverage-dart-lcov | |
| path: coverage/lcov.info | |
| if-no-files-found: error | |
| retention-days: 14 | |
| # Codecov tokenless OIDC (codecov-action v6+) works on PRs from | |
| # forks because it does not depend on repository secrets; the | |
| # runner mints a short-lived id-token via `id-token: write` on | |
| # this job and Codecov validates it against GitHub's OIDC issuer. | |
| # `fail_ci_if_error: false` keeps Codecov outages from breaking | |
| # the build (third-party SLO is not in our CI critical path). | |
| - name: Upload Dart coverage to Codecov | |
| if: | | |
| matrix.flutter == 'stable' && ( | |
| github.event_name == 'workflow_dispatch' || | |
| needs.changes.outputs.dart == 'true' || | |
| needs.changes.outputs.ci == 'true' | |
| ) | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 | |
| with: | |
| files: coverage/lcov.info | |
| flags: dart | |
| use_oidc: true | |
| fail_ci_if_error: false | |
| - name: Check Sonar Token presence | |
| if: matrix.flutter == 'stable' | |
| id: sonar-check | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| run: | | |
| if [ -n "$SONAR_TOKEN" ]; then | |
| echo "has_token=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_token=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: SonarCloud Scan | |
| if: | | |
| matrix.flutter == 'stable' && ( | |
| github.event_name == 'workflow_dispatch' || | |
| needs.changes.outputs.dart == 'true' || | |
| needs.changes.outputs.ci == 'true' | |
| ) && ( | |
| github.event_name == 'push' || | |
| steps.sonar-check.outputs.has_token == 'true' | |
| ) | |
| uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8.2.0 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| - name: Resolve example dependencies | |
| working-directory: example | |
| run: flutter pub get | |
| - name: Analyze example app | |
| working-directory: example | |
| run: flutter analyze | |
| build-gate: | |
| # Aggregator that resolves the matrix-skip naming gap. When the | |
| # `build` job's `if:` gate evaluates false, GitHub collapses both | |
| # matrix legs into one skipped check using the unexpanded template | |
| # name (`...(Flutter ${{ matrix.flutter }})`), so the per-leg | |
| # expanded names that branch protection would otherwise require | |
| # never appear in the rollup. This gate reports a single stable name | |
| # (`Dart tests gate`) and is the required-status-check entry on | |
| # `main` for the Dart side. | |
| # | |
| # Two distinct skip-causes need to be distinguished here: | |
| # 1. doc-only diff -> `build`'s `if:` gate evaluates false -> | |
| # `build` is skipped legitimately. Pass. | |
| # 2. `changes` itself fails (transient paths-filter error, | |
| # network blip) -> `build` is skipped because its `needs: | |
| # changes` dep failed. Fail, because the workflow did not | |
| # actually classify the diff and we have no signal that | |
| # this PR is safe to merge. | |
| # We discriminate by also depending on `changes` and requiring | |
| # its result to be `success` before treating any `build` skip as | |
| # acceptable. `Detect changed paths` is also listed as a | |
| # required status check on `main` for defense-in-depth. | |
| name: Dart tests gate | |
| runs-on: ubuntu-latest | |
| needs: [changes, build] | |
| if: always() | |
| steps: | |
| - name: Resolve build matrix outcome | |
| run: | | |
| if [[ "${{ needs.changes.result }}" != "success" ]]; then | |
| echo "changes job did not succeed (result: ${{ needs.changes.result }}); refusing to gate green" >&2 | |
| exit 1 | |
| fi | |
| case "${{ needs.build.result }}" in | |
| success|skipped) exit 0 ;; | |
| *) echo "build aggregate result: ${{ needs.build.result }}" >&2; exit 1 ;; | |
| esac | |
| doc-snippets: | |
| # Lightweight job that validates Dart code blocks in Markdown files using | |
| # `dart analyze`. Runs only when docs or the workflow itself changed, so | |
| # doc-only PRs don't force the full Rust toolchain / FRB codegen pipeline | |
| # that `rust-bridge-sync` requires. Flutter setup is needed because the | |
| # script resolves package imports via `flutter pub get`. | |
| name: Verify documentation snippets | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: | | |
| github.event_name == 'workflow_dispatch' || | |
| needs.changes.outputs.docs == 'true' || | |
| needs.changes.outputs.ci == 'true' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 | |
| with: | |
| channel: 'stable' | |
| cache: true | |
| - name: Resolve package dependencies | |
| run: flutter pub get | |
| - name: Verify documentation snippets | |
| run: dart run tool/check_doc_snippets.dart | |
| rust: | |
| name: Rust build + tests + coverage | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| # `id-token: write` is required for the Codecov OIDC tokenless | |
| # upload below. Same threat-model note as the `build` job: the | |
| # token is scoped to this repo's Codecov project and cannot reach | |
| # any other OIDC-trusting service. | |
| permissions: | |
| contents: read | |
| id-token: write | |
| # Skip when the diff doesn't touch Rust sources, the build hook, the | |
| # FRB config, the pubspec (FRB pin lives there), or the workflow file | |
| # itself. workflow_dispatch always runs everything. Coverage is part | |
| # of this job rather than a sibling so the gate above is the single | |
| # source of truth for "is the Rust pipeline relevant to this diff". | |
| if: | | |
| github.event_name == 'workflow_dispatch' || | |
| needs.changes.outputs.rust == 'true' || | |
| needs.changes.outputs.ci == 'true' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| - name: Cache cargo registry + crate target | |
| uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| with: | |
| workspaces: rust | |
| - name: Build Rust crate (--locked) | |
| working-directory: rust | |
| run: cargo build --locked | |
| - name: Run Rust unit tests | |
| working-directory: rust | |
| run: cargo test --lib --locked | |
| # Promote the curated `[lints.clippy]` set in `rust/Cargo.toml` to | |
| # CI errors. The table uses `level = "warn"` so the `-D warnings` | |
| # flag is what gates the merge; without it the lints would emit | |
| # diagnostics but not fail the job. `--lib --tests` matches the | |
| # local invocation pinned in `AGENTS.md`'s quality-gate list. | |
| - name: Lint Rust crate (clippy -D warnings) | |
| working-directory: rust | |
| run: cargo clippy --lib --tests --locked -- -D warnings | |
| # Cross-compile *type-check* for the on-device JNI shim | |
| # (`src/android_init.rs`, gated `#[cfg(target_os = "android")]`). | |
| # The host build / test / coverage steps above never compile this | |
| # file -- it is even excluded from the tarpaulin denominator below -- | |
| # so an API break in the JNI bindings (such as the `jni` 0.22 | |
| # `Env`/`EnvUnowned` split that broke the v5 Android build) stays | |
| # invisible to them. A `cargo check` for the Android target closes | |
| # that gap. `ring`'s build script compiles target objects, so the | |
| # NDK clang must back CC/AR; ubuntu-latest ships the NDK at | |
| # $ANDROID_NDK_LATEST_HOME. API level 21 is used purely to select an | |
| # always-present per-API clang wrapper; it does not affect the | |
| # type-check result. | |
| - name: Add Android aarch64 Rust target | |
| run: rustup target add aarch64-linux-android | |
| - name: Type-check Android JNI shim (cargo check) | |
| working-directory: rust | |
| run: | | |
| set -euo pipefail | |
| ndk="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-}}" | |
| if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then | |
| echo "Android NDK not found (ANDROID_NDK_LATEST_HOME / ANDROID_NDK_HOME unset)" >&2 | |
| exit 1 | |
| fi | |
| api=21 | |
| tc="$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin" | |
| export CC_aarch64_linux_android="$tc/aarch64-linux-android${api}-clang" | |
| export AR_aarch64_linux_android="$tc/llvm-ar" | |
| export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$tc/aarch64-linux-android${api}-clang" | |
| cargo check --lib --locked --target aarch64-linux-android | |
| # Tarpaulin install is the dominant cold-build cost of the | |
| # coverage step (~3-5 min from source). Caching the binary by | |
| # runner OS + version keeps subsequent runs at ~5 s for this | |
| # step. The version is embedded in the cache key so a deliberate | |
| # bump (edit both lines below) actually invalidates the cache | |
| # and reinstalls; floating to whatever `latest` resolved to on a | |
| # cold miss would silently change CI behaviour. | |
| - name: Cache cargo-tarpaulin binary | |
| id: tarpaulin-cache | |
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 | |
| with: | |
| path: ~/.cargo/bin/cargo-tarpaulin | |
| key: cargo-tarpaulin-${{ runner.os }}-0.35.2 | |
| - name: Install cargo-tarpaulin | |
| if: steps.tarpaulin-cache.outputs.cache-hit != 'true' | |
| run: cargo install cargo-tarpaulin --locked --version 0.35.2 | |
| # `--lib` mirrors the test-runner step above so coverage and | |
| # unit-test execution see the same target set. `--skip-clean` | |
| # reuses the build artifacts produced by `cargo build` / | |
| # `cargo test` above (tarpaulin defaults to a clean rebuild | |
| # otherwise, doubling wall time). `--out Lcov` emits | |
| # rust/coverage/lcov.info, the format Codecov accepts without | |
| # additional conversion. | |
| # | |
| # `--exclude-files` patterns are evaluated relative to the | |
| # tarpaulin manifest dir (`rust/`) and must mirror both the | |
| # `ignore:` block in `.codecov.yml` (so the uploaded artifact | |
| # and the dashboard report agree on the denominator) and the | |
| # `exclude_files` list in `rust/tarpaulin.toml` (so contributors | |
| # running `cd rust && cargo tarpaulin` locally see the same | |
| # denominator as CI). The flags here are functionally redundant | |
| # with `rust/tarpaulin.toml` — tarpaulin loads `[default]` | |
| # automatically — but kept explicit so the filtered set is | |
| # discoverable from the workflow without opening a second file. | |
| # See `DEVELOPMENT.md` → "Coverage exclusion policy" for the | |
| # decision tree governing additions to this list. Four files | |
| # are filtered: | |
| # - `src/frb_generated.rs`: regenerated verbatim by | |
| # `flutter_rust_bridge_codegen generate`; the rust-bridge-sync | |
| # job is the source of truth for its correctness, not coverage. | |
| # - `src/android_init.rs` / `src/ios_init.rs`: JNI / Obj-C++ | |
| # init shims only loaded on-device; unreachable from the | |
| # `ubuntu-latest` runner that produces the rust flag. | |
| # - `src/api/simple.rs`: holds only the `#[frb(init)]` | |
| # lifecycle hook (`init_app`). Fires on dylib load, which | |
| # `cargo test --lib` does not perform; same justification | |
| # as the `*_init.rs` shims above. | |
| - name: Run Rust coverage (cargo-tarpaulin) | |
| working-directory: rust | |
| run: | | |
| cargo tarpaulin --lib --locked --skip-clean \ | |
| --exclude-files 'src/frb_generated.rs' \ | |
| --exclude-files 'src/android_init.rs' \ | |
| --exclude-files 'src/ios_init.rs' \ | |
| --exclude-files 'src/api/simple.rs' \ | |
| --out Lcov --output-dir coverage | |
| - name: Upload Rust coverage artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: coverage-rust-lcov | |
| path: rust/coverage/lcov.info | |
| if-no-files-found: error | |
| retention-days: 14 | |
| # Same OIDC-tokenless flow as the Dart upload above; `flags: | |
| # rust` keeps the Dart and Rust components separable in | |
| # Codecov's UI so a regression in one language does not | |
| # mask coverage of the other. | |
| - name: Upload Rust coverage to Codecov | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 | |
| with: | |
| files: rust/coverage/lcov.info | |
| flags: rust | |
| use_oidc: true | |
| fail_ci_if_error: false | |
| rust-bridge-sync: | |
| name: Verify FRB bindings are in sync | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| # Drift check only matters when Rust source, the generated bindings, | |
| # or the workflow itself moved. Bindings-only edits (someone | |
| # hand-editing lib/src/ffi/) still trigger this so the regenerator | |
| # catches the drift. Doc-only changes are validated by the lighter | |
| # `doc-snippets` job instead, which avoids spinning up the full | |
| # Rust toolchain + FRB codegen pipeline for Markdown edits. | |
| if: | | |
| github.event_name == 'workflow_dispatch' || | |
| needs.changes.outputs.rust == 'true' || | |
| needs.changes.outputs.bindings == 'true' || | |
| needs.changes.outputs.ci == 'true' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| - name: Cache cargo registry + crate target | |
| uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 | |
| with: | |
| workspaces: rust | |
| - name: Cache flutter_rust_bridge_codegen binary | |
| id: frb-cache | |
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 | |
| with: | |
| path: ~/.cargo/bin/flutter_rust_bridge_codegen | |
| key: frb-codegen-2.12.0-${{ runner.os }} | |
| - name: Install flutter_rust_bridge_codegen (=2.12.0) | |
| if: steps.frb-cache.outputs.cache-hit != 'true' | |
| run: cargo install flutter_rust_bridge_codegen --version "=2.12.0" --locked | |
| - name: Setup Flutter | |
| uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 | |
| with: | |
| channel: 'stable' | |
| - name: Resolve package dependencies | |
| run: flutter pub get | |
| - name: Install FVM and link Flutter SDK | |
| run: | | |
| dart pub global activate fvm | |
| echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH | |
| # Reuse the SDK already installed by subosito/flutter-action so FVM | |
| # does not re-download the stable channel (~1.4 GB) into its own | |
| # cache. The symlink target matches `.fvmrc` (`"flutter": "stable"`) | |
| # so `fvm flutter` resolves the linked SDK without a download. | |
| mkdir -p "$HOME/fvm/versions" | |
| ln -sfn "$FLUTTER_ROOT" "$HOME/fvm/versions/stable" | |
| - name: Verify FVM resolves to the linked SDK | |
| run: fvm flutter --version | |
| - name: Regenerate bindings + assert no drift | |
| # Single source of truth for drift detection; the same script runs | |
| # locally via `dart run tool/check_bindings.dart`. | |
| run: dart run tool/check_bindings.dart | |
| # Run clippy against the freshly-regenerated `frb_generated.rs` so | |
| # any new lint shape the codegen starts emitting (one the | |
| # `#[allow(...)]` set on `mod frb_generated;` in `lib.rs` does not | |
| # already cover) surfaces here rather than as a CI break in an | |
| # unrelated downstream PR. The duplication with the `rust` job's | |
| # clippy step is intentional: that job runs on whatever | |
| # `frb_generated.rs` is checked in, this job runs on the | |
| # post-regeneration version. | |
| - name: Lint regenerated bindings (clippy -D warnings) | |
| working-directory: rust | |
| run: cargo clippy --lib --tests --locked -- -D warnings | |
| dependency-review: | |
| # Supply-chain gate: flags newly-introduced dependencies with | |
| # CVEs at or above the configured severity threshold by diffing | |
| # the PR against the base ref. PR-scoped because the action | |
| # requires a base..head diff; `push` events to main have no | |
| # equivalent context. Free on public repos (no GitHub Advanced | |
| # Security required). Covers both pubspec (Dart) and Cargo.toml | |
| # (Rust) via GitHub's dependency graph. | |
| # | |
| # `fail-on-severity: high` is the starting floor; tighten to | |
| # `moderate` once we observe real signal volume. License policy | |
| # (`allow-licenses` / `deny-licenses`) is intentionally not | |
| # configured yet -- add it here when the policy is decided. | |
| name: Dependency review | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 | |
| - name: Review dependencies | |
| # Pinned to v5.0.0 (May 2026) rather than the floating `v5` tag | |
| # so dependency / Node-runtime bumps land deliberately. v5.0.0 | |
| # ships `runs.using: node24`, removing the earlier need for the | |
| # `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24` env opt-in. Inputs/outputs | |
| # unchanged from v4.9.0; sole behavioural diff in the upstream | |
| # changelog is a fix for the patched-version display when | |
| # advisories use non-strict semver ranges (e.g. Maven beta | |
| # versions). `fail-on-severity` semantics unchanged. | |
| uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 | |
| with: | |
| fail-on-severity: high | |
| hooks-syntax: | |
| # POSIX-shell syntax + presence + exec-bit check for the repo-tracked | |
| # git hooks under `tool/hooks/`. Catches three regression shapes only: | |
| # a script that no longer parses (`sh -n`), a hook that has been | |
| # deleted or renamed out from under the explicit required list, and | |
| # a hook that has lost its executable bit (git silently skips | |
| # non-executable hooks). It does NOT validate that the policy logic | |
| # inside each hook is correct -- a hook that parses but no longer | |
| # refuses commits/pushes on `main` will still pass this job. | |
| # Functional coverage lives in the sibling `hooks-behaviour` job | |
| # below, which stages real commits and real merges in a throwaway | |
| # repo and invokes `pre-push` directly with synthetic refs/SHAs on | |
| # stdin (git's documented pre-push contract: read updates from | |
| # stdin, exit non-zero to abort), then asserts on exit codes and | |
| # stderr content. | |
| # None of the heavy jobs above touch these files (they sit outside | |
| # `rust:`, `bindings:`, and `dart:` filters). The sibling | |
| # `hooks-behaviour` job below would also fail on a missing or | |
| # non-executable hook because its test fixture invokes each hook | |
| # by path, but it is a heavier signal (provisions a throwaway repo, | |
| # stages real commits and merges) and reports a different failure | |
| # shape (assertion mismatch rather than parse/presence/exec-bit | |
| # error). This job exists for the cheaper, more targeted signal: | |
| # a parse error or a missing/chmod-stripped hook file is reported | |
| # in seconds with an actionable message, before any of the runtime | |
| # plumbing runs. | |
| # | |
| # The job runs on three trigger shapes: | |
| # - `hooks` true -> a hook script changed; verify it | |
| # - `ci` true -> the workflow itself changed; verify *this job* | |
| # can still validate the hooks (catches edits | |
| # that would silently neuter the guard) | |
| # - workflow_dispatch -> manual run for branch-protection drills | |
| # | |
| # The validation step enumerates the expected set of hooks rather | |
| # than globbing `tool/hooks/*`. A glob would silently pass on a PR | |
| # that deletes, renames, or chmod-strips a hook -- git treats | |
| # missing or non-executable hook files as no-ops, so a PR that | |
| # quietly disables the enforcement layer would still merge green. | |
| # The explicit list fails closed for that shape. | |
| name: Hooks shell-syntax check | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: >- | |
| needs.changes.outputs.hooks == 'true' | |
| || needs.changes.outputs.ci == 'true' | |
| || github.event_name == 'workflow_dispatch' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 | |
| - name: Verify each required hook is present, executable, and parses | |
| run: | | |
| set -eu | |
| # The `required_hooks` list below is the source of truth for | |
| # what this job validates; the per-hook bullets in | |
| # DEVELOPMENT.md "Local hook setup" must stay in sync with | |
| # it. The drift check after the parse loop fails CI when a | |
| # new hook script is added under tool/hooks/ without an | |
| # entry here, so the list cannot silently fall behind. | |
| required_hooks="pre-commit pre-merge-commit pre-push" | |
| rc=0 | |
| for hook in $required_hooks; do | |
| path="tool/hooks/$hook" | |
| if [ ! -f "$path" ]; then | |
| echo "ERROR: $path is missing" >&2 | |
| rc=1 | |
| continue | |
| fi | |
| if [ ! -x "$path" ]; then | |
| echo "ERROR: $path is not executable (git silently skips non-executable hooks)" >&2 | |
| rc=1 | |
| continue | |
| fi | |
| echo "checking $path" | |
| sh -n "$path" || rc=1 | |
| done | |
| # Drift check: every executable file under tool/hooks/ that | |
| # matches a known git client-hook name must appear in | |
| # required_hooks. test_hooks.sh is excluded by name (it is a | |
| # test harness, not a git hook). The list of valid client-hook | |
| # names is taken from `git help hooks` (githooks(5)) -- update | |
| # if git ever ships a new client-hook name we want to enforce. | |
| known_hooks="applypatch-msg pre-applypatch post-applypatch \ | |
| pre-commit pre-merge-commit prepare-commit-msg commit-msg \ | |
| post-commit pre-rebase post-checkout post-merge pre-push \ | |
| post-rewrite sendemail-validate fsmonitor-watchman \ | |
| p4-changelist p4-prepare-changelist p4-post-changelist \ | |
| p4-pre-submit post-index-change pre-auto-gc \ | |
| reference-transaction" | |
| for path in tool/hooks/*; do | |
| [ -f "$path" ] || continue | |
| base=$(basename "$path") | |
| [ "$base" = "test_hooks.sh" ] && continue | |
| in_known=0 | |
| for known in $known_hooks; do | |
| [ "$base" = "$known" ] && { in_known=1; break; } | |
| done | |
| [ "$in_known" -eq 1 ] || continue | |
| in_required=0 | |
| for req in $required_hooks; do | |
| [ "$base" = "$req" ] && { in_required=1; break; } | |
| done | |
| if [ "$in_required" -eq 0 ]; then | |
| echo "ERROR: tool/hooks/$base is a recognised git client hook but is not in required_hooks" >&2 | |
| echo " add it to the required_hooks list in this job and to DEVELOPMENT.md 'Local hook setup'" >&2 | |
| rc=1 | |
| fi | |
| done | |
| exit "$rc" | |
| hooks-behaviour: | |
| # Runtime behaviour check for the repo-tracked git hooks. Sibling | |
| # to `hooks-syntax`: that job catches parse-time and presence | |
| # regressions, this one catches the gap `sh -n` cannot -- a script | |
| # that parses cleanly but fails to enforce policy at runtime. | |
| # The round-9 unquoted-heredoc bug (set -u + unexpanded `$m` in | |
| # the recovery recipe -> hook aborts before printing the recipe) | |
| # is the canonical example; `tool/hooks/test_hooks.sh` carries an | |
| # explicit assertion sentinel for it. | |
| # | |
| # Trigger gating mirrors `hooks-syntax`. Both jobs are cheap | |
| # (<10s on ubuntu-latest) and independent, so they run in | |
| # parallel rather than sharing a runner. | |
| name: Hooks behaviour check | |
| runs-on: ubuntu-latest | |
| needs: changes | |
| if: >- | |
| needs.changes.outputs.hooks == 'true' | |
| || needs.changes.outputs.ci == 'true' | |
| || github.event_name == 'workflow_dispatch' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 | |
| - name: Run functional hook tests | |
| run: sh tool/hooks/test_hooks.sh |