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
61 changes: 36 additions & 25 deletions .claude/rules/ci-change-detection.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# CI Change Detection and Job Gating

Every *downstream* PR job in `.github/workflows/ci.yml` is gated by an output
of the `detect-changes` job. Two are deliberately ungated: `detect-changes`
itself, which produces the outputs, and `pre-commit`, which every gated job
declares in `needs:` and which must therefore always run. Those gates decide
whether a change occupies four self-hosted NPU machines for ten minutes or
finishes in ninety seconds on a GitHub runner. They are cheap to get subtly wrong and the symptom is silent —
a job that should have skipped merely *passes*, so nobody notices until a
flake in it reddens an unrelated PR.
Every *downstream* PR job in `.github/workflows/ci.yml` and the emergency CPU
lane in `.github/workflows/ci-self-cpu.yml` is gated by an output of the
canonical `.github/workflows/_detect-changes.yml` reusable workflow. Two jobs
are deliberately ungated in the main CI path: `detect-changes` itself, which
produces the outputs, and `pre-commit`, which every gated job declares in
`needs:` and which must therefore always run. Those gates decide whether a
change occupies four self-hosted NPU machines for ten minutes or finishes in
ninety seconds on a GitHub runner. They are cheap to get subtly wrong and the
symptom is silent — a job that should have skipped merely *passes*, so nobody
notices until a flake in it reddens an unrelated PR.

This rule is about keeping the gates honest. It is not a description of the
current filters; those change. It is the set of invariants they must satisfy.
Expand Down Expand Up @@ -36,9 +38,10 @@ A file belongs in `NON_CODE` when changing it cannot change what the code does
- `mkdocs.yml` sits at the repo root and is pure docs tooling. **In.**
- `.github/workflows/docs.yml` is a workflow, and is also pure docs tooling —
and it runs unconditionally on every PR, so it is its own gate. **In.**
- `.github/workflows/ci.yml` defines the gates themselves. **Out, always.** A
change to the gating must run everything, including the jobs it might have
just switched off.
- `.github/workflows/ci.yml`, `.github/workflows/ci-self-cpu.yml`, and the
reusable `.github/workflows/_*.yml` CI implementation workflows define gates
or job bodies. **Out, always.** A change to CI implementation must run
everything, including the jobs it might have just switched off.

The corollary is a habit, not a pattern: **adding a top-level config file or a
tooling workflow is a change-detection event.** Ask whether `NON_CODE` needs to
Expand Down Expand Up @@ -94,13 +97,13 @@ 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.

The vocabulary exists twice. `ci-self-cpu.yml` runs its own lane-local
`detect-changes` with the same outputs, and its file header says the two must
be kept in sync. That has now failed twice — `examples_only` never reached the
lane (#1607), and `tests_only` did not either (#1635) — because the gate tables
here only name `ci.yml`. Any change to an axis therefore means two files:
`.github/workflows/ci.yml` and `.github/workflows/ci-self-cpu.yml`, same commit,
and the outputs-consistency grep in §7 runs against both.
The vocabulary exists once, in `.github/workflows/_detect-changes.yml`. Both
`.github/workflows/ci.yml` and `.github/workflows/ci-self-cpu.yml` call that
workflow and consume the same output names. Do not reintroduce a lane-local
copy. That copy drifted twice — `examples_only` never reached the CPU lane
(#1607), and `tests_only` did not either (#1635). Any change to an axis now
means one detector file plus any consumers whose `if:` expression actually
changes.

### Write every axis in the same shape

Expand Down Expand Up @@ -176,15 +179,23 @@ Confirm the jobs you expected to skip report `skipping`, not `pass`. **A gating
bug shows up as a green check, so "CI passed" is not evidence.** Where the
change is to `NON_CODE` itself, replay the pattern locally against
representative file lists — a docs-only set, a `.gitignore`-only set, a
single-arch set, a `ci.yml` set — and check all four land where you intended
before pushing. Where the change is to an *axis*, also verify both workflow
copies expose the same output set — the lane drifted twice because only
`ci.yml` was checked:
single-arch set, a CI workflow set — and check all four land where you
intended before pushing. Where the change is to an *axis*, also verify both callers
consume only outputs declared by the canonical reusable workflow:

```bash
for f in ci.yml ci-self-cpu.yml; do
echo "== $f"; grep -oE 'outputs\.[a-z_]+' ".github/workflows/$f" | sort -u
done # the two lists must be identical
echo "== declared by _detect-changes.yml"
awk '
/^jobs:/ { in_outputs = 0; seen_jobs = 1; next }
!seen_jobs && /^ outputs:/ { in_outputs = 1; next }
in_outputs && /^ [a-z0-9_]+:/ { in_outputs = 0 }
in_outputs && /^ [a-z0-9_]+:/ { print }
' .github/workflows/_detect-changes.yml | sed 's/[ :]*//g' | sort -u

echo "== consumed by callers"
grep -h -oE 'needs\.detect-changes\.outputs\.[a-z0-9_]+' \
.github/workflows/ci.yml .github/workflows/ci-self-cpu.yml |
sed 's/.*outputs\.//' | sort -u
Comment thread
ChaoWao marked this conversation as resolved.
```

## Relation to the other rules
Expand Down
13 changes: 13 additions & 0 deletions .github/actions/cache-pip/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Cache pip packages
description: Restore and save the per-runner pip cache.

runs:
using: composite
steps:
- name: Cache pip packages
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*.py') }}
restore-keys: |
${{ runner.os }}-pip-
48 changes: 48 additions & 0 deletions .github/actions/setup-venv/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Set up venv
description: Create .venv and install Python dependencies.

inputs:
packages:
description: Arguments passed to pip install after torch, for example ".[test]" or "-e .[test]".
required: false
default: ""
install-torch:
description: Install the CPU torch wheel before packages.
required: false
default: "true"
system-site-packages:
description: Create the venv with --system-site-packages.
required: false
default: "false"
source-cann:
description: Source the CANN environment before creating the venv.
required: false
default: "false"

runs:
using: composite
steps:
- name: Create venv and install dependencies
shell: bash
env:
PACKAGES: ${{ inputs.packages }}
run: |
set -f
if [ "${{ inputs.source-cann }}" = "true" ]; then
source /usr/local/Ascend/cann/set_env.sh
fi

VENV_ARGS=()
if [ "${{ inputs.system-site-packages }}" = "true" ]; then
VENV_ARGS+=(--system-site-packages)
fi

python3 -m venv "${VENV_ARGS[@]}" .venv
source .venv/bin/activate
pip install --upgrade pip
if [ "${{ inputs.install-torch }}" = "true" ]; then
pip install torch --index-url https://download.pytorch.org/whl/cpu
fi
if [ -n "$PACKAGES" ]; then
pip install $PACKAGES
fi
228 changes: 228 additions & 0 deletions .github/workflows/_detect-changes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
name: Detect Changes

permissions:
contents: read

on:
workflow_call:
inputs:
repository:
description: "Repository containing the head ref to classify."
required: false
type: string
ref:
description: "Head ref or SHA to classify."
required: false
type: string
base_repository:
description: "Canonical base repository used for merge-base calculation."
required: false
type: string
base_ref:
description: "Canonical base branch to fetch when base_sha is not enough or not provided."
required: false
default: main
type: string
base_sha:
description: "Exact base SHA, usually github.event.pull_request.base.sha."
required: false
type: string
head_sha:
description: "Exact head SHA, usually github.event.pull_request.head.sha."
required: false
type: string
runs_on:
description: "JSON-encoded runs-on value for the detector job."
required: false
default: '["ubuntu-latest"]'
type: string
outputs:
a2a3_changed:
description: "Whether the diff can affect a2a3."
value: ${{ jobs.detect.outputs.a2a3_changed }}
a5_changed:
description: "Whether the diff can affect a5."
value: ${{ jobs.detect.outputs.a5_changed }}
non_code_only:
description: "Whether every changed file is non-code."
value: ${{ jobs.detect.outputs.non_code_only }}
st_affected:
description: "Whether scene tests can be affected."
value: ${{ jobs.detect.outputs.st_affected }}
ut_affected:
description: "Whether unit tests can be affected."
value: ${{ jobs.detect.outputs.ut_affected }}
examples_only:
description: "Whether every code change is under examples/."
value: ${{ jobs.detect.outputs.examples_only }}
tests_only:
description: "Whether every code change is under tests/."
value: ${{ jobs.detect.outputs.tests_only }}

jobs:
detect:
name: detect-changes
runs-on: ${{ fromJSON(inputs.runs_on) }}
outputs:
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 }}
examples_only: ${{ steps.check.outputs.examples_only }}
tests_only: ${{ steps.check.outputs.tests_only }}
steps:
- name: Checkout target ref
uses: actions/checkout@v5
with:
repository: ${{ inputs.repository || github.repository }}
ref: ${{ inputs.ref || inputs.head_sha || github.sha }}
fetch-depth: 0
Comment thread
ChaoWao marked this conversation as resolved.
persist-credentials: false

- name: Check file changes
id: check
env:
BASE_REPOSITORY: ${{ inputs.base_repository || github.repository }}
BASE_REF: ${{ inputs.base_ref || 'main' }}
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
run: |
HEAD_REV=HEAD
if [ -n "$HEAD_SHA" ]; then
if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
HEAD_REV="$HEAD_SHA"
else
echo "head_sha $HEAD_SHA is not present after checkout; using HEAD"
fi
fi

BASE_URL="https://github.com/${BASE_REPOSITORY}.git"
BASE_REMOTE="refs/remotes/base/${BASE_REF}"
BASE_FETCHED=false
if git fetch --no-tags "$BASE_URL" "+refs/heads/${BASE_REF}:${BASE_REMOTE}"; then
BASE_FETCHED=true
else
echo "base fetch failed for ${BASE_REPOSITORY}/${BASE_REF}; treating diff as unattributable"
fi

BASE=""
if [ -n "$BASE_SHA" ]; then
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
git fetch --no-tags "$BASE_URL" "$BASE_SHA" || true
fi
BASE="$BASE_SHA"
elif [ "$BASE_FETCHED" = "true" ]; then
if ! BASE=$(git merge-base "$BASE_REMOTE" "$HEAD_REV"); then
echo "merge-base failed for $BASE_REMOTE...$HEAD_REV; treating diff as unattributable"
BASE=""
fi
fi

# `run:` is `bash -e`, so a failing git diff would abort the step
# before the guard below and take every output with 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 [ -z "$BASE" ] || ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
echo "No usable base commit: cannot attribute changes"
FILES=""
elif ! FILES=$(git diff --name-only "${BASE}...${HEAD_REV}"); then
echo "git diff failed (unresolved base/head SHA?); treating as unattributable"
FILES=""
fi

# Fail open, in ONE place. No usable file list means attribution is
# impossible, so every affected flag must say "run".
if [ -z "$FILES" ]; then
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"
echo "examples_only=false" >> "$GITHUB_OUTPUT"
echo "tests_only=false" >> "$GITHUB_OUTPUT"
exit 0
fi

echo "Changed files:"
echo "$FILES"

# ONE definition of "changing this cannot change what the code does".
# Every gate below derives from this set. CI implementation workflows
# are deliberately absent: a change to gates or job bodies must run
# everything.
NON_CODE='^(docs/|\.docs/|\.claude/|mkdocs\.yml$|\.github/workflows/docs\.yml$|\.gitignore$|\.pre-commit-config\.yaml$)|\.md$'

CODE=$(echo "$FILES" | grep -vE "$NON_CODE" || true)
if [ -z "$CODE" ]; then
echo "non_code_only=true" >> "$GITHUB_OUTPUT"
echo "No file here can change what the code does; pre-commit + docs are the whole gate"
else
echo "non_code_only=false" >> "$GITHUB_OUTPUT"
fi

# Architecture axis: skip a partition only when every changed file is
# in the sibling partition or non-code.
A5_ONLY='^(src/a5/|examples/a5/|tests/(st|ut/cpp)/a5/)'
A2A3_REMAINING=$(echo "$FILES" | grep -vE "$A5_ONLY" | grep -vE "$NON_CODE" || true)
if [ -n "$A2A3_REMAINING" ]; then
echo "a2a3_changed=true" >> "$GITHUB_OUTPUT"
echo "Files affecting a2a3:"
echo "$A2A3_REMAINING"
else
echo "a2a3_changed=false" >> "$GITHUB_OUTPUT"
echo "All changes are a5-only or non-code; skipping a2a3"
fi

A2A3_ONLY='^(src/a2a3/|examples/a2a3/|tests/(st|ut/cpp)/a2a3/)'
A5_REMAINING=$(echo "$FILES" | grep -vE "$A2A3_ONLY" | grep -vE "$NON_CODE" || true)
if [ -n "$A5_REMAINING" ]; then
echo "a5_changed=true" >> "$GITHUB_OUTPUT"
echo "Files affecting a5:"
echo "$A5_REMAINING"
else
echo "a5_changed=false" >> "$GITHUB_OUTPUT"
echo "All changes are a2a3-only or non-code; skipping a5"
fi

# Test-category axis, same shape as the arch axis.
ST_ONLY='^(tests/st/|examples/)'
UT_ONLY='^tests/ut/'

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

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

# Corpus axis: product-build jobs read neither examples/ nor tests/
# as a corpus, so a diff confined to either partition cannot reach
# them and is covered by the scene-test job that reads the payload.
EXAMPLES_ONLY='^examples/'
NON_EXAMPLE=$(echo "$FILES" | grep -vE "$EXAMPLES_ONLY" | grep -vE "$NON_CODE" || true)
if [ -n "$NON_EXAMPLE" ]; then
echo "examples_only=false" >> "$GITHUB_OUTPUT"
else
echo "examples_only=true" >> "$GITHUB_OUTPUT"
echo "All changes are under examples/ or non-code; skipping packaging and the profiling-flag smoke"
fi

TESTS_ONLY='^(tests/)'
NON_TEST=$(echo "$FILES" | grep -vE "$TESTS_ONLY" | grep -vE "$NON_CODE" || true)
if [ -n "$NON_TEST" ]; then
echo "tests_only=false" >> "$GITHUB_OUTPUT"
else
echo "tests_only=true" >> "$GITHUB_OUTPUT"
echo "All changes are under tests/ or non-code; skipping packaging and the profiling-flag smoke"
fi
Loading
Loading