Add backend preflight and generic agent skills #1486
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] | |
| pull_request: | |
| # Cancel in-progress runs for the same branch/PR | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| build-and-test: | |
| name: Build & Test | |
| runs-on: ubuntu-latest | |
| # Pin to bash so `${PIPESTATUS[0]}` (a bash-only array) in the Build and | |
| # Run tests steps is guaranteed — GitHub Actions defaults to bash on Linux | |
| # in practice, but making it explicit avoids silent breakage if the | |
| # default ever shifts. | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| # We deliberately do NOT cache [_opam/] here. setup-ocaml@v3 caches its | |
| # own opam-root state at [~/.opam/] (compiler + repo metadata) keyed | |
| # separately from anything we'd cache for [_opam/]. Caching the local | |
| # switch alongside means the two can drift: a stale [~/.opam/] without | |
| # the switch registered makes opam treat [_opam/] as a fresh switch and | |
| # try to reinstall packages whose files still exist on disk — the | |
| # classic "zarith ... META already exists" symptom we kept hitting | |
| # whenever a cancel-in-progress run saved a half-consistent [_opam/]. | |
| # [dune-cache: true] handles the much larger speed win (avoiding | |
| # recompilation), and the opam.ocaml.org archive mirror keeps the | |
| # `opam install` step under ~90s by serving precompiled binaries. | |
| - uses: ocaml/setup-ocaml@e32b06a3e831ff2fbc6f08cf35be2085e3918014 # v3.6.1 | |
| with: | |
| ocaml-compiler: '5.4.0' | |
| dune-cache: true | |
| - name: Use opam.ocaml.org archive cache | |
| # erratique.ch (upstream for cmdliner, mtime, fpath, logs, uutf, ...) | |
| # is frequently flaky; the community cache mirrors opam-repository | |
| # archives behind a CDN, so opam only falls back to upstream on a miss. | |
| run: opam option 'archive-mirrors=["https://opam.ocaml.org/cache"]' | |
| - name: Install dependencies | |
| run: opam install . --deps-only --with-test -y | |
| - name: Build | |
| id: build | |
| run: | | |
| opam exec -- dune build 2>&1 | tee build.log | |
| exit ${PIPESTATUS[0]} | |
| - name: Annotate build errors | |
| if: failure() && steps.build.outcome == 'failure' | |
| run: | | |
| # Parse OCaml compiler errors into GitHub annotations | |
| python3 -c " | |
| import re, sys | |
| log = open('build.log').read() | |
| # Match: File \"path\", line N, characters C-C: | |
| for m in re.finditer(r'File \"([^\"]+)\", line (\d+), characters (\d+)-\d+:\n(.*?)(?=\nFile |\Z)', log, re.DOTALL): | |
| f, line, col, msg = m.groups() | |
| msg = msg.strip().replace('\n', ' ') | |
| level = 'error' if 'Error' in msg else 'warning' | |
| print(f'::{level} file={f},line={line},col={col}::{msg}') | |
| " | |
| - name: Run tests | |
| # Single invocation — the runtest alias covers unit, expect, inline, | |
| # and QCheck property tests, so a separate `--force` re-run would just | |
| # repeat the entire suite. QCHECK_COUNT raises iteration depth for the | |
| # property tests; non-property tests ignore it. | |
| run: | | |
| opam exec -- dune runtest 2>&1 | tee test.log | |
| exit ${PIPESTATUS[0]} | |
| env: | |
| QCHECK_COUNT: "10000" | |
| - name: Annotate test failures | |
| if: failure() && hashFiles('test.log') != '' | |
| run: | | |
| python3 -c " | |
| import re | |
| log = open('test.log').read() | |
| for m in re.finditer(r'File \"([^\"]+)\", line (\d+).*?:\s*(.*?FAILED.*)', log): | |
| f, line, msg = m.groups() | |
| print(f'::error file={f},line={line}::{msg.strip()}') | |
| for m in re.finditer(r'FAIL\s+(.+?)\s+in\s+(\S+)\s+at\s+line\s+(\d+)', log): | |
| name, f, line = m.groups() | |
| print(f'::error file={f},line={line}::Test failed: {name}') | |
| " | |
| format: | |
| name: Format Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Cache opam switch | |
| uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 | |
| with: | |
| path: _opam | |
| key: opam-${{ runner.os }}-ocaml-5.4.0-${{ hashFiles('*.opam') }} | |
| - uses: ocaml/setup-ocaml@e32b06a3e831ff2fbc6f08cf35be2085e3918014 # v3.6.1 | |
| with: | |
| ocaml-compiler: '5.4.0' | |
| - name: Use opam.ocaml.org archive cache | |
| run: opam option 'archive-mirrors=["https://opam.ocaml.org/cache"]' | |
| - uses: ocaml/setup-ocaml/lint-fmt@e32b06a3e831ff2fbc6f08cf35be2085e3918014 # v3.6.1 | |
| shell-lint: | |
| name: Shell Lint & Format | |
| runs-on: ubuntu-latest | |
| env: | |
| SHFMT_VERSION: v3.13.1 | |
| # 2-space indent + case-item indent matches the existing scripts/ style. | |
| SHFMT_FLAGS: "-i 2 -ci" | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Discover shell scripts | |
| id: discover | |
| # Match by extension *and* by shebang so files like git hooks or | |
| # scripts without a `.sh` suffix still get checked. `git ls-files -z` | |
| # respects .gitignore and avoids globbing surprises. | |
| run: | | |
| set -euo pipefail | |
| # `if … fi` (not `&&`) so a non-matching grep doesn't fail the | |
| # subshell under `set -e` and abort the whole pipeline. | |
| { | |
| git ls-files -z '*.sh' '*.bash' | |
| git ls-files -z | while IFS= read -r -d '' f; do | |
| [ -f "$f" ] || continue | |
| case "$f" in *.sh|*.bash) continue ;; esac | |
| # Only read the first line; skip binaries and large files cheaply. | |
| if head -c 128 -- "$f" 2>/dev/null | head -n 1 \ | |
| | grep -Eq '^#![[:space:]]*((/usr/bin/env[[:space:]]+)?|/usr/bin/|/bin/)(bash|dash|ksh|sh)([[:space:]]|$)'; then | |
| printf '%s\0' "$f" | |
| fi | |
| done | |
| } | sort -zu | tee "$RUNNER_TEMP/shell-files.nul" >/dev/null | |
| count=$(tr -cd '\0' < "$RUNNER_TEMP/shell-files.nul" | wc -c | tr -d ' ') | |
| echo "count=$count" >> "$GITHUB_OUTPUT" | |
| echo "Discovered $count shell script(s):" | |
| tr '\0' '\n' < "$RUNNER_TEMP/shell-files.nul" | |
| - name: Install shfmt | |
| if: steps.discover.outputs.count != '0' | |
| # Checksums pin the exact binary for SHFMT_VERSION; bumping the version | |
| # requires regenerating both. Computed via: | |
| # sha256sum shfmt_v3.13.1_linux_{amd64,arm64} | |
| env: | |
| SHFMT_SHA256_LINUX_AMD64: fb096c5d1ac6beabbdbaa2874d025badb03ee07929f0c9ff67563ce8c75398b1 | |
| SHFMT_SHA256_LINUX_ARM64: 32d92acaa5cd8abb29fc49dac123dc412442d5713967819d8af2c29f1b3857c7 | |
| run: | | |
| set -euo pipefail | |
| arch=$(uname -m) | |
| case "$arch" in | |
| x86_64) shfmt_arch="linux_amd64"; expected_sha="$SHFMT_SHA256_LINUX_AMD64" ;; | |
| aarch64) shfmt_arch="linux_arm64"; expected_sha="$SHFMT_SHA256_LINUX_ARM64" ;; | |
| *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; | |
| esac | |
| curl -fsSL -o "$RUNNER_TEMP/shfmt" \ | |
| "https://github.com/mvdan/sh/releases/download/${SHFMT_VERSION}/shfmt_${SHFMT_VERSION}_${shfmt_arch}" | |
| echo "${expected_sha} ${RUNNER_TEMP}/shfmt" | sha256sum -c - | |
| chmod +x "$RUNNER_TEMP/shfmt" | |
| sudo mv "$RUNNER_TEMP/shfmt" /usr/local/bin/shfmt | |
| shfmt --version | |
| - name: shellcheck | |
| if: steps.discover.outputs.count != '0' | |
| run: | | |
| set -euo pipefail | |
| shellcheck --version | |
| xargs -0 shellcheck < "$RUNNER_TEMP/shell-files.nul" | |
| - name: shfmt --diff | |
| if: steps.discover.outputs.count != '0' | |
| run: | | |
| set -euo pipefail | |
| # shellcheck disable=SC2086 # SHFMT_FLAGS intentionally word-splits | |
| xargs -0 shfmt $SHFMT_FLAGS -d < "$RUNNER_TEMP/shell-files.nul" |