Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 61 additions & 15 deletions .claude/rules/ci-change-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,74 @@ never sit behind a *more permissive* condition than a GitHub-hosted one. If you
find yourself writing a looser `if:` for the more expensive job, the vocabulary
is wrong (see §1) — fix that instead of widening the gate.

## 4. Two axes, and why the arch axis already implies the other
## 4. Three axes, layered — and the non-code one is subtracted first

| Axis | Output | Used by |
| Axis | Output | Answers |
| ---- | ------ | ------- |
| non-code | `non_code_only` | arch-agnostic jobs: `ut`, `packaging-matrix`, `ut-a2a3`, `ut-a5` |
| architecture | `a2a3_changed` / `a5_changed` | arch-specific jobs: `st-sim-*`, `st-onboard-*`, `profiling-flags-smoke` |
| non-code | `non_code_only` | can this diff change what the code does at all? |
| architecture | `a2a3_changed` / `a5_changed` | which silicon can it reach? |
| test category | `st_affected` / `ut_affected` | which suite can it break? |

These are layered, not redundant. `a2a3_changed` and `a5_changed` are computed
by subtracting `NON_CODE` *first*, so a non-code-only change already makes both
false. An arch-gated job therefore needs no separate non-code check, and adding
one would be noise. An arch-agnostic job cannot use the arch axis at all.
They are layered, not redundant. **Every arch and category flag subtracts
`NON_CODE` before deciding**, so a non-code-only change already makes all four
false. An arch- or category-gated job therefore needs no separate non-code
check, and adding one would be noise.

Keep that asymmetry deliberate. If a new job is arch-specific, gate it on the
arch axis; if not, on `non_code_only`. Mixing them per-job is how §1 gets
violated again.
A job composes the axes it is actually subject to:

| Job family | Gate |
| ---------- | ---- |
| `st-sim-*`, `st-onboard-*` | `<arch>_changed && st_affected` |
| `profiling-flags-smoke` | `(a2a3_changed \|\| a5_changed) && st_affected` |
| `ut`, `ut-a2a3`, `ut-a5` | `non_code_only != true && ut_affected` |
| `packaging-matrix` | `non_code_only != true` |

The UT jobs stay off the arch axis on purpose — unit tests cover shared
contracts, so the cost of a falsely-skipped regression outweighs the minutes.
That is a decision about the *arch* axis only; the category axis is a different
question, because a scene-test-only change genuinely cannot break a unit test.

### Write every axis in the same shape

The arch and category axes use one pattern: **a partition is unaffected only
when every changed file belongs exclusively to a sibling partition.**

```bash
SIBLING='^(...)' # what this partition is NOT
REMAINING=$(echo "$FILES" | grep -vE "$SIBLING" | grep -vE "$NON_CODE" || true)
[ -n "$REMAINING" ] && flag=true || flag=false
```

Two properties come for free, and both are why a new axis must copy the shape
rather than invent one:

- **Fail-safe direction.** Any path the patterns do not recognise survives the
filters, so it lands in `REMAINING` and turns the flag *on*. New directories
over-run CI rather than silently skipping it.
- **Shared infrastructure resolves correctly without being enumerated.** The
root `conftest.py`, `pyproject.toml`, `simpler_setup/` and `tests/lint/`
belong to no single partition, so they match no `SIBLING` and flip every flag
true — which is what they should do, since both suites load them.

## 5. Fail open once, at the top

An empty file list means attribution is impossible — a PR with no files, or
base/head SHAs that did not resolve. Every flag must then report "affected",
and **that decision belongs in one guard before any pattern runs**, not inside
each flag's branch.
No usable file list means attribution is impossible — a PR with no files, or a
`git diff` that failed on unresolved base/head SHAs. Every flag must then report
"affected", and **that decision belongs in one guard before any pattern runs**,
not inside each flag's branch.

"No usable list" covers both cases, and the failure one is the easier to miss.
`run:` is `bash -e`, so a non-zero `git diff` aborts the step before any guard —
and a failed `detect-changes` leaves every downstream `needs:` unsatisfied, which
**skips** the matrix instead of running it. That is fail-closed, the opposite of
the intent. Let only the exit status decide and fold a failure into the empty
case:

```bash
if ! FILES=$(git diff --name-only "$BASE"..."$HEAD"); then
FILES=""
fi
```

Deciding it per-flag is how the axes drift apart. The emptiness test once lived
only inside the `non_code_only` branch, so an empty diff left that flag `false`
Expand Down
64 changes: 53 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ jobs:
# ---------- Unit tests (no hardware, Python + C++) ----------
ut:
needs: [detect-changes, pre-commit]
if: needs.detect-changes.outputs.non_code_only != 'true'
if: needs.detect-changes.outputs.non_code_only != 'true' && needs.detect-changes.outputs.ut_affected == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
Expand Down Expand Up @@ -163,7 +163,7 @@ jobs:
# ---------- Simulation scene tests ----------
st-sim-a2a3:
needs: [detect-changes, pre-commit]
if: needs.detect-changes.outputs.a2a3_changed == 'true'
if: needs.detect-changes.outputs.a2a3_changed == 'true' && needs.detect-changes.outputs.st_affected == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
env:
Expand Down Expand Up @@ -257,7 +257,7 @@ jobs:

st-sim-a5:
needs: [detect-changes, pre-commit]
if: needs.detect-changes.outputs.a5_changed == 'true'
if: needs.detect-changes.outputs.a5_changed == 'true' && needs.detect-changes.outputs.st_affected == 'true'
runs-on: ${{ matrix.os }}
timeout-minutes: 30
env:
Expand Down Expand Up @@ -375,7 +375,7 @@ jobs:
# corresponding profiling block — no cross-contamination from cmake cache.
profiling-flags-smoke:
needs: [detect-changes, pre-commit]
if: needs.detect-changes.outputs.a2a3_changed == 'true' || needs.detect-changes.outputs.a5_changed == 'true'
if: (needs.detect-changes.outputs.a2a3_changed == 'true' || needs.detect-changes.outputs.a5_changed == 'true') && needs.detect-changes.outputs.st_affected == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30

Expand Down Expand Up @@ -473,7 +473,7 @@ jobs:
# ---------- Unit tests (a2a3 hardware, Python + C++) ----------
ut-a2a3:
needs: [detect-changes, pre-commit]
if: needs.detect-changes.outputs.non_code_only != 'true'
if: needs.detect-changes.outputs.non_code_only != 'true' && needs.detect-changes.outputs.ut_affected == 'true'
runs-on: [self-hosted, a2a3]
timeout-minutes: 30
env:
Expand Down Expand Up @@ -559,7 +559,7 @@ jobs:
# ---------- Scene tests (a2a3 hardware) ----------
st-onboard-a2a3:
needs: [detect-changes, pre-commit]
if: needs.detect-changes.outputs.a2a3_changed == 'true'
if: needs.detect-changes.outputs.a2a3_changed == 'true' && needs.detect-changes.outputs.st_affected == 'true'
runs-on: [self-hosted, a2a3]
timeout-minutes: 60
env:
Expand Down Expand Up @@ -733,6 +733,8 @@ jobs:
a2a3_changed: ${{ steps.check.outputs.a2a3_changed }}
a5_changed: ${{ steps.check.outputs.a5_changed }}
non_code_only: ${{ steps.check.outputs.non_code_only }}
st_affected: ${{ steps.check.outputs.st_affected }}
ut_affected: ${{ steps.check.outputs.ut_affected }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
Expand All @@ -741,20 +743,32 @@ jobs:
- name: Check file changes
id: check
run: |
FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }})
# `run:` is `bash -e`, so a failing `git diff` would abort the step
# before the guard below and take every output with it — and a
# `detect-changes` failure leaves downstream `needs:` unsatisfied, which
# SKIPS the matrix rather than running it. Let only the exit status
# decide, and turn a failure into an empty list so the fail-open guard
# handles both cases identically.
if ! FILES=$(git diff --name-only \
${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}); then
echo "git diff failed (unresolved base/head SHA?); treating as unattributable"
FILES=""
fi

# Fail open, in ONE place. An empty diff means attribution is
# Fail open, in ONE place. No usable file list means attribution is
# impossible — a PR with no files, or base/head SHAs that did not
# resolve — so nothing below can be trusted and every flag must say
# "affected". Deciding this per-flag is how the axes drifted apart:
# the emptiness test used to live inside the non_code_only branch
# only, which left it false (UT and packaging ran) while both arch
# flags independently came out false too (scene tests skipped).
if [ -z "$FILES" ]; then
echo "Empty diff: cannot attribute changes, running everything"
echo "No usable file list: cannot attribute changes, running everything"
echo "non_code_only=false" >> "$GITHUB_OUTPUT"
echo "a2a3_changed=true" >> "$GITHUB_OUTPUT"
echo "a5_changed=true" >> "$GITHUB_OUTPUT"
echo "st_affected=true" >> "$GITHUB_OUTPUT"
echo "ut_affected=true" >> "$GITHUB_OUTPUT"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
exit 0
fi

Expand Down Expand Up @@ -803,10 +817,38 @@ jobs:
echo "All changes are a2a3-only or non-code; skipping a5"
fi

# Test-category axis, same shape as the arch axis: a category is
# unaffected only when EVERY changed file belongs exclusively to the
# other one. Shared test infra (root conftest.py, pyproject.toml,
# simpler_setup/, tests/lint/) matches neither ST_ONLY nor UT_ONLY, so
# it correctly flips both. The two suites execute disjoint trees —
# `pytest examples tests/st` vs `pytest tests/ut` + the cpp ctest —
# and no unit test reads examples/ or tests/st/.
ST_ONLY='^(tests/st/|examples/)'
UT_ONLY='^tests/ut/'

# Scene tests are irrelevant only when every change is UT-exclusive.
ST_REMAINING=$(echo "$FILES" | grep -vE "$UT_ONLY" | grep -vE "$NON_CODE" || true)
if [ -n "$ST_REMAINING" ]; then
echo "st_affected=true" >> "$GITHUB_OUTPUT"
else
echo "st_affected=false" >> "$GITHUB_OUTPUT"
echo "All changes are unit-test-only or non-code; skipping scene tests"
fi

# Unit tests are irrelevant only when every change is ST-exclusive.
UT_REMAINING=$(echo "$FILES" | grep -vE "$ST_ONLY" | grep -vE "$NON_CODE" || true)
if [ -n "$UT_REMAINING" ]; then
echo "ut_affected=true" >> "$GITHUB_OUTPUT"
else
echo "ut_affected=false" >> "$GITHUB_OUTPUT"
echo "All changes are scene-test/example-only or non-code; skipping unit tests"
fi

# ---------- Unit tests (a5 hardware, Python + C++) ----------
ut-a5:
needs: [detect-changes, pre-commit]
if: needs.detect-changes.outputs.non_code_only != 'true'
if: needs.detect-changes.outputs.non_code_only != 'true' && needs.detect-changes.outputs.ut_affected == 'true'
runs-on: [self-hosted, a5]
timeout-minutes: 30
env:
Expand Down Expand Up @@ -876,7 +918,7 @@ jobs:

st-onboard-a5:
needs: [detect-changes, pre-commit]
if: needs.detect-changes.outputs.a5_changed == 'true'
if: needs.detect-changes.outputs.a5_changed == 'true' && needs.detect-changes.outputs.st_affected == 'true'
runs-on: [self-hosted, a5]
timeout-minutes: 60
env:
Expand Down
Loading
Loading