Skip to content

chore: release v5.2.1: documentation audit fixes #667

chore: release v5.2.1: documentation audit fixes

chore: release v5.2.1: documentation audit fixes #667

Workflow file for this run

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