CI Scope-Targeted Testing¶
Tracked in: Issue #375
Problem¶
Every PR update — including a bare "Update branch" that only imports an automated chore: bump version commit — re-runs the full CI pipeline. All lint jobs, all unit tests, and a 50-minute Docker integration suite fire unconditionally. The only existing gates are: Azure credentials missing (secret-dependent tests skip gracefully) or a manually added label. Everything else always runs.
This produces two problems:
- Wrong time — CI re-runs on housekeeping imports where the feature code hasn't changed and the result is predetermined.
- Wrong scope — a change to a single crawler's PowerShell script triggers the full integration suite including crawlers that were never touched, and the 50-minute load soak.
The goal is: test the right things, for the right reasons, at the right time.
Current pipeline overview¶
| Workflow | Trigger | Duration | Current skip logic |
|---|---|---|---|
pr.yml |
pull_request |
~5 min | None |
pr-integration.yml |
pull_request |
~50 min | paths-ignore on changes/**, docs/**, **.md |
docker-publish.yml |
workflow_run from bump-version |
~15 min | N/A — runs on merge to main only, never on PRs |
Jobs in pr.yml: lint-ps, lint-js, unit-tests (Pester), unit-js (Vitest API), unit-ui (Vitest UI), node-launcher-ui-build, openapi, audit, hygiene, crawler-manifest, docs-nav, pr-summary.
Jobs in pr-integration.yml: integration (core + crawler tests), e2e (Playwright), load-soak.
Why paths-ignore alone doesn't solve the "Update branch" problem¶
After "Update branch" (GitHub merges main into the feature branch), the PR diff is evaluated between the new main base and the updated feature head. The bump files (CHANGES.md, setup/IdentityAtlas.psd1, changes/*.md) are now equal on both sides — they were just merged in — so they don't appear as changed files. CI re-runs not because of those files, but because a new commit was pushed (pull_request: synchronize fires unconditionally). paths-ignore has nothing to exclude because the product code is still in the PR diff.
Scope-to-test matrix¶
What each change surface should trigger:
| What changed | PS lint | Pester unit | API vitest | UI vitest | OpenAPI | Crawler tests (specific) | Crawler tests (all) | E2e | Load soak |
|---|---|---|---|---|---|---|---|---|---|
tools/crawlers/<type>/ only |
✅ | — | — | ✅ | — | ✅ (that type + dependents) | — | — | ✅ |
tools/crawlers/shared/ |
✅ | ✅ | — | — | — | — | ✅ | — | ✅ |
tools/powershell-sdk/ |
✅ | ✅ | — | — | — | — | ✅ | — | ✅ |
setup/docker/Invoke-CrawlerJob.ps1 / IdentityAtlas.psm1 |
✅ | ✅ | — | — | — | — | ✅ | — | ✅ |
app/api/src/ (non-migration) |
— | — | ✅ | — | — | — | — | — | ✅ |
app/api/src/db/migrations/ |
— | — | ✅ | — | — | — | — | — | ✅ |
app/api/src/openapi.yaml |
— | — | — | — | ✅ | — | — | — | — |
app/ui/src/ |
— | — | — | ✅ | — | — | — | ✅ | — |
tools/crawlers/*/ConfigWizard.jsx / CrawlerMeta.js / Summary.jsx |
— | — | — | ✅ | — | — | — | — | — |
| Dockerfiles / compose files | — | — | — | — | — | — | ✅ | ✅ | ✅ |
Housekeeping only (CHANGES.md, psd1, changes/) |
— | — | — | — | — | — | — | — | — |
Load soak triggers¶
Load soak exists to catch regressions in throughput and query performance that unit tests cannot see at volume. It must run automatically when any processing path changes:
Automatic triggers:
app/api/src/**
tools/crawlers/shared/**
tools/powershell-sdk/**
tools/crawlers/*/Start-*.ps1
setup/docker/Invoke-CrawlerJob.ps1
setup/docker/Dockerfile.powershell
docker-compose*.yml
Skipped when only:
app/ui/src/**
tools/crawlers/*/ConfigWizard.jsx|CrawlerMeta.js|Summary.jsx|*.test.*
docs/** changes/** *.md setup/IdentityAtlas.psd1
The load-soak label is a force-trigger supplement for cases where you want a soak run on a PR that wouldn't automatically qualify (e.g. a targeted query optimisation). It does not replace the automatic triggers above.
Crawler dependency model¶
Test obligation propagates upward to dependents, not downward to dependencies.
| What changed | What to test | What to run (setup only, no assertions) |
|---|---|---|
odata changed |
odata + omada (omada depends on odata — odata changes can break omada even with no omada code changes) |
— |
omada changed |
omada only |
odata (run first to populate data omada's test needs; odata's behavior hasn't changed) |
Two sets¶
CRAWLERS_TO_TEST = changed types ∪ transitive dependents (traverse upward)
CRAWLERS_TO_RUN = CRAWLERS_TO_TEST ∪ transitive dependencies of CRAWLERS_TO_TEST
(traverse downward, for data setup)
The PS integration runner already has the full registry and the topological sort. Only CRAWLERS_TO_TEST needs to be passed in as input; the script derives CRAWLERS_TO_RUN itself. The failure gate becomes: if ($failed -and $type -in $CrawlersToTest) { $totalFailed++ }. Types in CRAWLERS_TO_RUN but not in CRAWLERS_TO_TEST run for setup and their exit code is ignored.
Implementation plan¶
Phase 1 — Commit-range skip + job-level path gates (~1 day)¶
Gets the most common pain points: housekeeping re-runs and the integration suite firing on UI-only changes.
1a. Commit-range skip (both workflows)¶
Add a changes pre-job that detects whether the only new commits since the last CI run are bump-version housekeeping. The checkout uses fetch-depth: 0 so the full history is available for the range query.
Why not
paths-ignore? After "Update branch", the PR diff includes all the branch's product files.paths-ignorecan't exclude them — they're legitimately changed relative to the new base. We detect housekeeping at the commit-message level instead.
# Detects whether this push added any non-housekeeping commits.
# Downstream jobs guard on: needs.changes.outputs.has_product_changes == 'true'
# Passing on a housekeeping-only push (result=false) is INTENTIONAL — it means
# "nothing meaningful changed since last CI run; skip everything".
changes:
runs-on: ubuntu-latest
outputs:
has_product_changes: ${{ steps.check.outputs.result }}
steps:
- uses: actions/checkout@...
with:
fetch-depth: 0
- id: check
run: |
# First push to a new branch: before is all-zeros. Always run CI.
if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
echo "result=true" >> "$GITHUB_OUTPUT"; exit 0
fi
# After a rebase or force-push, event.before is no longer in the
# rewritten history. Treat this as a real change (safe default).
if ! git merge-base --is-ancestor "${{ github.event.before }}" "${{ github.sha }}" 2>/dev/null; then
echo "result=true" >> "$GITHUB_OUTPUT"; exit 0
fi
# Collect subjects of commits added by this push. Filter blank lines
# so an empty range (no new commits) doesn't produce a false positive.
NON_HOUSEKEEPING=$(
git log "${{ github.event.before }}..${{ github.sha }}" --format="%s" \
| grep -v '^$' \
| grep -cvE "^chore: bump version|^Merge branch 'main'" \
|| echo 0
)
if [ "$NON_HOUSEKEEPING" -gt 0 ]; then
echo "result=true" >> "$GITHUB_OUTPUT"
echo "Found $NON_HOUSEKEEPING non-housekeeping commit(s)" >> "$GITHUB_STEP_SUMMARY"
else
echo "result=false" >> "$GITHUB_OUTPUT"
echo "CI skipped — no product code changes detected in this push. If this is wrong, check the commit range or add the \`integration-test\` label." >> "$GITHUB_STEP_SUMMARY"
fi
All downstream jobs add needs: [changes] and a top-level guard:
Jobs that also have a path filter (see 1b) wrap both conditions:
if: |
needs.changes.outputs.has_product_changes == 'true' &&
(needs.filter.outputs.ps == 'true' || needs.filter.outcome == 'failure')
paths-filter failure fallback: The
|| needs.filter.outcome == 'failure'clause is intentional. Ifdorny/paths-filterhas an outage or errors, its outputs are empty strings — all== 'true'comparisons evaluate to false, silently skipping everything and producing a false green. The fallback runs all applicable jobs when the filter step itself fails, which is the safe direction.
1b. Path-filter gates in pr.yml¶
Add a filter job using dorny/paths-filter@v3:
filter:
needs: [changes]
if: needs.changes.outputs.has_product_changes == 'true'
runs-on: ubuntu-latest
outputs:
ps: ${{ steps.f.outputs.ps }}
pester: ${{ steps.f.outputs.pester }}
api: ${{ steps.f.outputs.api }}
ui: ${{ steps.f.outputs.ui }}
openapi: ${{ steps.f.outputs.openapi }}
outcome: ${{ steps.f.outcome }}
steps:
- uses: actions/checkout@...
- uses: dorny/paths-filter@v3
id: f
with:
filters: |
ps:
- 'tools/crawlers/**/*.ps1'
- 'tools/powershell-sdk/**'
- 'setup/**/*.ps1'
- 'setup/IdentityAtlas.psm1'
pester:
- 'tools/powershell-sdk/**'
- 'tools/crawlers/shared/**'
- 'setup/docker/Invoke-CrawlerJob.ps1'
- 'setup/IdentityAtlas.psm1'
- 'test/unit/**'
api:
- 'app/api/src/**'
ui:
- 'app/ui/src/**'
- 'tools/crawlers/**/*.jsx'
- 'tools/crawlers/**/*.test.js'
- 'tools/crawlers/**/*.test.jsx'
openapi:
- 'app/api/src/openapi.yaml'
Job conditions (the || needs.filter.outputs.outcome == 'failure' fallback applies to all gated jobs):
| Job | Condition |
|---|---|
lint-ps |
filter.outputs.ps == 'true' |
lint-js |
filter.outputs.ui == 'true' |
unit-tests (Pester) |
filter.outputs.pester == 'true' |
unit-js (API Vitest) |
filter.outputs.api == 'true' |
unit-ui (UI Vitest) |
filter.outputs.ui == 'true' |
node-launcher-ui-build |
filter.outputs.ui == 'true' |
openapi |
filter.outputs.openapi == 'true' |
hygiene, crawler-manifest, docs-nav, audit |
always (fast hygiene checks — no path gate) |
1c. Path-filter gates in pr-integration.yml¶
Add an equivalent filter job and gate each heavy job. Also add setup/IdentityAtlas.psd1 to the existing paths-ignore block (it's a version-number-only file that never justifies a 50-minute run):
filter:
needs: [changes]
if: needs.changes.outputs.has_product_changes == 'true'
runs-on: ubuntu-latest
outputs:
integration: ${{ steps.f.outputs.integration }}
e2e: ${{ steps.f.outputs.e2e }}
load_soak: ${{ steps.f.outputs.load_soak }}
outcome: ${{ steps.f.outcome }}
steps:
- uses: actions/checkout@...
- uses: dorny/paths-filter@v3
id: f
with:
filters: |
integration:
- 'app/api/src/**'
- 'tools/crawlers/**/*.ps1'
- 'tools/crawlers/**/*.js'
- 'tools/crawlers/shared/**'
- 'tools/powershell-sdk/**'
- 'setup/docker/Invoke-CrawlerJob.ps1'
- 'setup/IdentityAtlas.psm1'
- 'app/api/Dockerfile'
- 'setup/docker/Dockerfile.powershell'
- 'docker-compose*.yml'
e2e:
- 'app/ui/src/**'
- 'tools/crawlers/**/*.jsx'
- 'app/api/src/**'
- 'app/api/Dockerfile'
load_soak:
- 'app/api/src/**'
- 'tools/crawlers/shared/**'
- 'tools/powershell-sdk/**'
- 'tools/crawlers/*/Start-*.ps1'
- 'setup/docker/Invoke-CrawlerJob.ps1'
- 'setup/docker/Dockerfile.powershell'
- 'docker-compose*.yml'
1d. ci-passed gate job¶
Replace pr-summary with a ci-passed gate that covers all 11 jobs. The gate passes when every job either succeeded or was legitimately skipped — this is intentional for housekeeping pushes.
ci-passed:
needs:
- changes
- filter
- lint-ps
- lint-js
- unit-tests
- unit-js
- unit-ui
- node-launcher-ui-build
- openapi
- hygiene
- crawler-manifest
- docs-nav
- audit
if: always()
runs-on: ubuntu-latest
steps:
- name: Check results
run: |
# A skipped job (has_product_changes=false) is intentionally allowed —
# it means CI correctly identified a housekeeping-only push.
FAILED=$(echo '${{ toJSON(needs) }}' \
| jq -r 'to_entries | .[] | select(.value.result == "failure") | .key')
if [ -n "$FAILED" ]; then
echo "## CI Failed" >> "$GITHUB_STEP_SUMMARY"
echo "The following jobs failed:" >> "$GITHUB_STEP_SUMMARY"
echo "$FAILED" | while read -r job; do
echo "- $job" >> "$GITHUB_STEP_SUMMARY"
done
exit 1
fi
echo "All checks passed or were legitimately skipped." >> "$GITHUB_STEP_SUMMARY"
Branch protection migration sequence (do not skip this):
- Add
ci-passedas a NEW required status check alongside the existing individual job requirements. - Merge this PR. Verify
ci-passedappears and passes on 2–3 real PRs. - Remove the old individual job requirements, keeping only
ci-passed.
Never remove the old requirements and add the new gate in the same step — there is a window where open PRs have neither set of checks and can merge unprotected.
Phase 2 — Per-crawler scoping in integration tests (~1 day)¶
Reduces the integration job from "test all crawlers every time" to "test only the crawlers that matter."
2a. Detect changed crawler types¶
Extend the filter job in pr-integration.yml with per-type filters. Use one filter key per crawler type — do not use tr '-' '_' to auto-generate keys as it produces wrong keys for multi-hyphen names like custom-connector. Map each type explicitly:
# Per-crawler type filters — one key per type, named to match the type slug.
# Extend this list when a new crawler is added to tools/crawlers/.
crawler_csv: ['tools/crawlers/csv/**']
crawler_entra_id: ['tools/crawlers/entra-id/**']
crawler_omada: ['tools/crawlers/omada/**']
crawler_midpoint: ['tools/crawlers/midpoint/**']
crawler_demo: ['tools/crawlers/demo/**']
crawler_custom_connector: ['tools/crawlers/custom-connector/**']
crawlers_all:
- 'tools/crawlers/shared/**'
- 'tools/powershell-sdk/**'
- 'setup/docker/Invoke-CrawlerJob.ps1'
- 'setup/IdentityAtlas.psm1'
- 'setup/docker/Dockerfile.powershell'
A follow-on step builds CRAWLERS_TO_TEST using direct GHA expression outputs rather than a jq parse of the JSON blob (safer — no fragility around key name encoding):
- id: scope
run: |
if [ "${{ steps.f.outputs.crawlers_all }}" = "true" ]; then
echo "crawlers_to_test=csv,entra-id,omada,midpoint,demo,custom-connector" >> "$GITHUB_OUTPUT"
else
LIST=""
[ "${{ steps.f.outputs.crawler_csv }}" = "true" ] && LIST="${LIST:+$LIST,}csv"
[ "${{ steps.f.outputs.crawler_entra_id }}" = "true" ] && LIST="${LIST:+$LIST,}entra-id"
[ "${{ steps.f.outputs.crawler_omada }}" = "true" ] && LIST="${LIST:+$LIST,}omada"
[ "${{ steps.f.outputs.crawler_midpoint }}" = "true" ] && LIST="${LIST:+$LIST,}midpoint"
[ "${{ steps.f.outputs.crawler_demo }}" = "true" ] && LIST="${LIST:+$LIST,}demo"
[ "${{ steps.f.outputs.crawler_custom_connector }}" = "true" ] && LIST="${LIST:+$LIST,}custom-connector"
echo "crawlers_to_test=$LIST" >> "$GITHUB_OUTPUT"
fi
# Emit to step summary so CI logs show why certain crawlers were included.
echo "CRAWLERS_TO_TEST: ${LIST:-all}" >> "$GITHUB_STEP_SUMMARY"
2b. Modify the PS integration runner¶
Pass $CrawlersToTest as an env var. The script:
- If
$CrawlersToTestis empty → run and test everything (current behaviour, unchanged) - Otherwise:
- Compute
$testSet= the given types + their transitive dependents (traversedependsOnupward through the registry) - Compute
$runSet=$testSet+ their transitive dependencies (traverse downward, for data setup) - Run all types in
$runSetin topological order - Assert results only for types in
$testSet; types in$runSet \ $testSetrun for setup and their exit code is ignored
The registry already contains all dependsOn information. No external dep-graph logic needed — the PS script computes both sets itself from the registry.
2c. integration-test label override¶
If the integration-test label is present on the PR, force CRAWLERS_TO_TEST to the full list regardless of paths. Useful for performance optimisations or infra changes that touch paths not covered by the filter.
Phase 3 — Crawler tests as visible parallel pipeline jobs (~2 days)¶
Currently the crawler integration test is one opaque step in the GHA UI. All crawlers run inside a single PowerShell block using Start-Job. You see nothing until the entire block finishes, and a failure in one crawler produces a wall of interleaved output with no per-crawler pass/fail signal in the pipeline view.
The goal is for each crawler to appear as its own named job in the GHA pipeline — independently green or red, with its own live log and timing.
The core constraint: jobs can't share a running Docker stack¶
GHA jobs run on separate ephemeral runners. A Docker stack started in job A is not reachable from job B. This means each matrix job must build its own full stack — expensive (~15 min without cache, ~3–5 min with warm GHA layer cache).
The solution is a setup job + DB seed artifact:
integration-setup (one job)
→ Build web + worker images (GHA layer cache)
→ Start full stack
→ Run mock-server crawlers that have no live-credential dependency
(odata via Start-MockODataServer.ps1; omada via Start-MockODataServer.ps1)
NOTE: entra-id is EXCLUDED from the seed — it requires live Azure credentials
and cannot be replicated for a generic shared seed. The entra-id matrix job
builds its own state when Azure secrets are present; it skips otherwise.
→ pg_dump -Fc -f ci-db-seed.dump (custom format — required for pg_restore --data-only)
→ upload as GHA artifact (name: ci-db-seed, retention-days: 1)
→ emit CRAWLERS_TO_TEST JSON array → GITHUB_OUTPUT (filtered by Phase 2 scope)
crawler-tests (matrix job, one entry per crawler in CRAWLERS_TO_TEST)
→ Build images from GHA layer cache (~3–5 min, warm)
→ Start fresh stack (postgres + API + worker)
→ API runs migrations on startup → empty schema created
→ wait for API ready
→ download ci-db-seed artifact
→ pg_restore --data-only -Fc ci-db-seed.dump
(--data-only: schema already exists from migrations; don't recreate it)
→ run Test-${{ matrix.crawler }}.ps1 -ApiBaseUrl ... -ApiKey ...
→ pass/fail independently (fail-fast: false)
Why pg_dump -Fc + pg_restore --data-only:
- -Fc (custom format) is required for pg_restore to work; plain SQL dumps use psql, not pg_restore.
- --data-only skips schema DDL during restore — the API already ran migrations and created the schema on startup. Restoring the schema again would conflict.
- This is fast: CI datasets are small (hundreds of rows), dump/restore is <5 seconds.
Why not share the built image via registry?¶
Pushing a PR-specific image to GHCR on every CI run adds ~2 min, consumes registry storage, and requires image cleanup. GHA's layer cache achieves the same warm-rebuild goal without registry involvement — the type=gha,scope=web cache already configured in pr-integration.yml is the right mechanism.
Short-term improvement (before Phase 3): ::group:: log markers¶
While the matrix restructure is in progress, add GHA log group markers to the PS runner so each crawler's output is at least collapsible in the current single-step view:
# Before running each crawler:
Write-Host "::group::Crawler: $type (depth $depth)"
Write-Host "::notice title=${type}::Starting at $(Get-Date -Format 'HH:mm:ss')"
# After Wait-Job, emit the captured log:
$output = Receive-Job -Job $job -Keep
$output | ForEach-Object { Write-Host $_ }
if ($failed) {
Write-Host "::error title=${type}::Test failed — see log above"
} else {
Write-Host "::notice title=${type}::Passed"
}
Write-Host "::endgroup::"
This requires switching from fire-and-forget Start-Job output to per-crawler captured output (redirect each job's stdout to a temp file, emit grouped after Wait-Job). The parallelism via Start-Job is preserved; the output is just buffered per crawler and emitted sequentially in labeled groups after all jobs at that depth level complete.
Result: collapsible per-crawler sections in the GHA log, ::error:: annotations surfaced in the PR summary, per-crawler pass/fail visible without scrolling through a wall of interleaved output. Takes ~2 hours; can ship independently of Phase 3.
Phase 3 YAML structure¶
jobs:
integration-setup:
outputs:
crawlers: ${{ steps.scope.outputs.crawlers_json }} # JSON array for matrix
steps:
- build web + worker images (GHA cache)
- start stack
- run setup-only crawlers (odata, midpoint — mock-server crawlers only; NOT entra-id)
- pg_dump -Fc -f ci-db-seed.dump
- uses: actions/upload-artifact@...
with:
name: ci-db-seed
path: ci-db-seed.dump
retention-days: 1
# Filter CRAWLERS_TO_TEST from Phase 2; emit as JSON array for matrix
- id: scope
run: |
# CRAWLERS_TO_TEST passed in as env var from Phase 2 filter job
# Convert comma-separated list to JSON array
JSON=$(echo "$CRAWLERS_TO_TEST" | tr ',' '\n' | jq -R . | jq -sc .)
echo "crawlers_json=$JSON" >> "$GITHUB_OUTPUT"
echo "CRAWLERS_TO_TEST: $CRAWLERS_TO_TEST" >> "$GITHUB_STEP_SUMMARY"
crawler-tests:
needs: [integration-setup, changes, filter]
if: needs.filter.outputs.integration == 'true' || needs.filter.outputs.outcome == 'failure'
strategy:
matrix:
crawler: ${{ fromJson(needs.integration-setup.outputs.crawlers) }}
fail-fast: false # one crawler failing doesn't cancel others
name: 'Crawler: ${{ matrix.crawler }}'
steps:
- build images from GHA cache
- start stack (fresh postgres — migrations run on API startup)
- wait for API ready
- uses: actions/download-artifact@...
with:
name: ci-db-seed
- run: pg_restore --data-only -Fc -d identity_atlas ci-db-seed.dump
- run: pwsh -File tools/crawlers/${{ matrix.crawler }}/Test-*.ps1 -ApiBaseUrl ... -ApiKey ...
# Gate job — required in branch protection instead of individual matrix job names.
# Dynamic matrix entries can't be individually required in GHA branch protection;
# this wrapper ensures the matrix passes/fails as a single named check.
ci-integration-passed:
needs: [integration-setup, crawler-tests, e2e, load-soak]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
FAILED=$(echo '${{ toJSON(needs) }}' \
| jq -r 'to_entries | .[] | select(.value.result == "failure") | .key')
if [ -n "$FAILED" ]; then
echo "## Integration CI Failed" >> "$GITHUB_STEP_SUMMARY"
echo "$FAILED" | while read -r job; do echo "- $job" >> "$GITHUB_STEP_SUMMARY"; done
exit 1
fi
echo "All integration checks passed or were skipped."
fail-fast: false is essential — one crawler failing should not cancel the remaining parallel jobs.
Branch protection for Phase 3: Update to require ci-integration-passed (from pr-integration.yml) instead of the individual integration, e2e, load-soak job names. Apply the same add-then-remove migration sequence as described in Phase 1d.
Acceptance criteria¶
- [ ] "Update branch" that only imports a
chore: bump versioncommit triggers no new CI run - [ ] A PR touching only
app/ui/src/skips PS lint, Pester, and the integration suite entirely - [ ] A PR touching only
tools/crawlers/omada/runs only omada's integration test (odata runs for setup, not assertion) - [ ] A PR touching
tools/crawlers/shared/runs all crawler integration tests - [ ] A PR touching
tools/powershell-sdk/runs Pester unit tests and all crawler integration tests - [ ] A PR touching
app/api/src/db/migrations/triggers the load soak automatically - [ ] Branch protection resolves correctly (
ci-passedsucceeds) when CI jobs are legitimately skipped - [ ] The
integration-testlabel forces the full integration suite regardless of paths - [ ] The
load-soaklabel forces load soak regardless of paths - [ ] Each crawler appears as its own named job in the GHA pipeline with independent pass/fail (Phase 3)
- [ ] A failing crawler does not cancel other crawler jobs (
fail-fast: false) (Phase 3) - [ ] Per-crawler log output is visible live (not buffered until the whole suite finishes) (Phase 3)
- [ ] Short-term: per-crawler
::group::log sections visible in the current single-step view before Phase 3 lands - [ ] Force-pushed or rebased branches run CI (commit-range skip does not false-negative on non-ancestor
beforeSHA) - [ ] CI produces informative step summary when jobs are skipped (housekeeping message) or fail (per-job failure list)