diff --git a/.circleci/config.yml b/.circleci/config.yml index 68d48b5dd5c..0c1a2ac1cf4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -87,17 +87,19 @@ orbs: jobs: # -------------------------------------------------------------------------- - # This job prepares the dynamic continuation pipeline in 6 steps: + # This job prepares the dynamic continuation pipeline in 7 steps: # # 1. Install yq (YAML processor for merging configs) # 2. Store string pipeline params → /tmp/pipeline-parameters.json # 3. Store boolean pipeline params → /tmp/pipeline-parameters.json # 4. Detect file-tree changes → /tmp/pipeline-parameters.json - # 5. Decision tree: read the JSON, enable c-run_* workflow flags, strip - # intermediate params, write final JSON + # 5. Routing policy (compute-workflow-conditions.sh): read the JSON, enable + # c-run_* workflow flags, strip intermediate params, write final JSON # 6. Merge continuation YAML configs → /tmp/merged-config.yml # 7. Call CircleCI continuation API with merged config + JSON params # + # The routing data (schedules, dispatch flags, change patterns, passthrough + # params) is declarative in routing.yml; the scripts only hold the logic. # The result: only workflows whose c-run_* flag is true will execute. # -------------------------------------------------------------------------- prepare-continuation-config: @@ -141,8 +143,8 @@ jobs: # Same as above but for booleans. Separate step because CircleCI renders # boolean pipeline params as 0/1 in environment blocks — the "bool" mode # normalizes them to JSON true/false. - # These dispatch flags are consumed only by the decision tree below; - # they do NOT reach continuation configs. + # These dispatch flags are consumed only by the routing policy + # (compute-workflow-conditions.sh); they do NOT reach continuation configs. - run: name: Store boolean pipeline parameters in JSON file command: .circleci/scripts/collect-params.sh bool @@ -160,37 +162,24 @@ jobs: c-rust_ci_dispatch: << pipeline.parameters.rust_ci_dispatch >> c-rust_e2e_dispatch: << pipeline.parameters.rust_e2e_dispatch >> c-l2_fork_test_dispatch: << pipeline.parameters.l2_fork_test_dispatch >> - # Each env var value is a POSIX Extended Regular Expression (ERE, like grep -E). - # Matched line-by-line against `git diff --name-only origin/develop...HEAD`. - # Result: the c-* key is set to true/false in the JSON. + # Change-detection patterns live in routing.yml (change_patterns.any and + # change_patterns.all). Each is a POSIX ERE matched line-by-line against + # `git diff --name-only origin/develop...HEAD`; collect-params.sh writes + # the resulting c- boolean into the JSON. # # Test a pattern locally: # git diff --name-only origin/develop...HEAD | grep -E "^your/pattern/" - # - # When to add: you need path-based workflow gating. Also add a corresponding - # is_true/run call in the decision tree below. - run: name: Detect file tree changes and store in JSON file command: .circleci/scripts/collect-params.sh detect - environment: - c-circleci_changed: "^\\.circleci/" - c-contracts_changed: "^(packages/contracts-bedrock|op-core/forks|op-core/nuts|\\.circleci|\\.github|ops/check-changed)/|^(package\\.json|mise\\.toml)$" - c-docs_changes_detected: "^docs/public-docs/" - c-rust_changes_detected: "^(rust|op-e2e|\\.circleci)/" - # Safer "docs-only" detection: true iff EVERY changed file is under docs/public-docs/. - # Using an inclusion (all-match) check instead of an exclusion of known code categories - # makes the fast path safe-by-default — any path not enumerated by the patterns above - # still falls through to the full main workflow. - run: name: Detect docs-only changes (all-match) command: .circleci/scripts/collect-params.sh detect_all - environment: - c-only_docs_changes: "^docs/public-docs/" - # Decision tree: determines which workflows to enable based on trigger, - # branch, and params. The logic lives in compute-workflow-conditions.sh - # (using the run/is_true/param/finalize helpers from workflow-helpers.sh); - # each enabled c-run_ maps 1:1 to - # "when: << pipeline.parameters.c-run_ >>" in a continuation config. + # Routing policy: enables the c-run_* workflow flags based on trigger, + # branch, schedule, and the detected params. The workflow lists are data + # in routing.yml; the conditions live in compute-workflow-conditions.sh. + # Each c-run_ maps 1:1 to "when: << pipeline.parameters.c-run_ >>" + # in a continuation config under .circleci/continue/. - run: name: Compute workflow conditions from pipeline parameters and store in JSON file environment: diff --git a/.circleci/continue/main.yml b/.circleci/continue/main.yml index f6383a9df24..b42232b0164 100644 --- a/.circleci/continue/main.yml +++ b/.circleci/continue/main.yml @@ -86,7 +86,7 @@ parameters: go-cache-version: type: string default: "v0.1" - # Workflow activation flags (set by the decision tree in config.yml) + # Workflow activation flags (set by compute-workflow-conditions.sh) c-run_main: type: boolean default: false diff --git a/.circleci/continue/rust-ci.yml b/.circleci/continue/rust-ci.yml index a2a9c8479be..8d8e80cd04f 100644 --- a/.circleci/continue/rust-ci.yml +++ b/.circleci/continue/rust-ci.yml @@ -17,7 +17,7 @@ parameters: c-base_image: type: string default: default - # Workflow activation flags (set by the decision tree in config.yml) + # Workflow activation flags (set by compute-workflow-conditions.sh) c-run_rust_ci: type: boolean default: false diff --git a/.circleci/continue/rust-e2e.yml b/.circleci/continue/rust-e2e.yml index 9bf504baf66..2618c5936c6 100644 --- a/.circleci/continue/rust-e2e.yml +++ b/.circleci/continue/rust-e2e.yml @@ -17,7 +17,7 @@ parameters: c-go-cache-version: type: string default: "v0.1" - # Workflow activation flags (set by the decision tree in config.yml) + # Workflow activation flags (set by compute-workflow-conditions.sh) c-run_rust_e2e_ci: type: boolean default: false diff --git a/.circleci/routing.yml b/.circleci/routing.yml new file mode 100644 index 00000000000..b31102b40bc --- /dev/null +++ b/.circleci/routing.yml @@ -0,0 +1,84 @@ +# Declarative routing data for the CircleCI setup pipeline. +# +# This file is the single source of truth for WHAT workflows run; the policy +# script (.circleci/scripts/compute-workflow-conditions.sh) decides WHEN they +# run. Keeping the data here means the workflow lists can be read directly by +# tooling (e.g. test-schedule-triggers.js) instead of parsing the script. +# +# Workflow names below map 1:1 to a "c-run_" boolean parameter declared +# in a continuation config under .circleci/continue/. Enabling c-run_ +# makes that continuation workflow execute. + +# Scheduled pipelines: CircleCI schedule name -> workflows it triggers. +# The schedule names must match the triggers configured in the CircleCI UI +# (verified live by test-schedule-triggers.js). +schedules: + build_four_hours: + - scheduled_todo_issues + - scheduled_cannon_full_tests + build_daily: + - scheduled_preimage_reproducibility + - scheduled_stale_check + - scheduled_heavy_fuzz_tests + - scheduled_daily_tests + - circleci_schedule_trigger_check + +# API triggers: dispatch flag -> workflows enabled when that flag is set. +# Most flags fire on a simple "is the flag true?" check. main_dispatch and +# labeled_pr have more complex conditions (see compute-workflow-conditions.sh), +# but the workflows they enable still live here as data. +api_dispatch: + main_dispatch: + - main + - contracts_feature_tests + fault_proofs_dispatch: + - develop_fault_proofs + kontrol_dispatch: + - develop_kontrol_tests + cannon_full_test_dispatch: + - scheduled_cannon_full_tests + reproducibility_dispatch: + - scheduled_preimage_reproducibility + stale_check_dispatch: + - scheduled_stale_check + heavy_fuzz_dispatch: + - scheduled_heavy_fuzz_tests + publish_contract_artifacts_dispatch: + - publish_contract_artifacts + l2_fork_test_dispatch: + - l2_fork_test + rust_ci_dispatch: + - rust_ci + rust_e2e_dispatch: + - rust_e2e_ci + labeled_pr: + - close_issue + +# Change-detection patterns. Each value is a POSIX Extended Regular Expression +# (ERE, like grep -E) matched line-by-line against the changed file list +# (git diff --name-only origin/develop...HEAD). collect-params.sh reads these. +# +# Test a pattern locally: +# git diff --name-only origin/develop...HEAD | grep -E "^your/pattern/" +change_patterns: + # "any": c- is true iff AT LEAST ONE changed file matches. + any: + circleci_changed: "^\\.circleci/" + contracts_changed: "^(packages/contracts-bedrock|op-core/forks|op-core/nuts|\\.circleci|\\.github|ops/check-changed)/|^(package\\.json|mise\\.toml)$" + docs_changes_detected: "^docs/public-docs/" + rust_changes_detected: "^(rust|op-e2e|\\.circleci)/" + # "all": c- is true iff EVERY changed file matches (and there is at + # least one). Used for the safe-by-default docs-only fast path: any path not + # enumerated still falls through to the full main workflow. + all: + only_docs_changes: "^docs/public-docs/" + +# Pipeline parameters forwarded to continuation configs. Everything else in the +# parameters JSON is stripped before the continuation step (see finalize). +passthrough_params: + - c-default_docker_image + - c-rust_base_image + - c-base_image + - c-github-event-base64 + - c-go-cache-version + - c-publish_contract_artifacts_ref diff --git a/.circleci/scripts/collect-params.sh b/.circleci/scripts/collect-params.sh index e39f2a7fb46..87643d5675e 100755 --- a/.circleci/scripts/collect-params.sh +++ b/.circleci/scripts/collect-params.sh @@ -4,15 +4,17 @@ # Called once per type with a mode argument: # collect-params.sh str — emit all c-* env vars as JSON strings # collect-params.sh bool — emit all c-* env vars as JSON booleans (normalizes 0/1) -# collect-params.sh detect — treat c-* env var values as ERE patterns; true iff ANY changed file matches -# collect-params.sh detect_all — treat c-* env var values as ERE patterns; true iff EVERY changed file matches (and there is at least one) +# collect-params.sh detect — match routing.yml change_patterns.any against changed files; c- true iff ANY file matches +# collect-params.sh detect_all — match routing.yml change_patterns.all against changed files; c- true iff EVERY file matches (and there is at least one) # +# str/bool read c-* env vars (CircleCI pipeline params). detect/detect_all read +# their ERE patterns from routing.yml so the patterns are declarative data. # Each invocation appends to /tmp/pipeline-parameters.json. -# Env vars whose name starts with c- are processed; all others are ignored. set -euo pipefail -MODE="${1:?Usage: collect-params.sh }" +MODE="${1:?Usage: collect-params.sh }" OUTPUT="/tmp/pipeline-parameters.json" +ROUTING="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/routing.yml" [ -f "${OUTPUT}" ] || echo '{}' > "${OUTPUT}" @@ -40,14 +42,17 @@ case "${MODE}" in ;; detect|detect_all) + [[ "${MODE}" == "detect_all" ]] && section=".change_patterns.all" || section=".change_patterns.any" + CHANGED=$(git diff --name-only "origin/${BASE_REVISION}...HEAD" 2>/dev/null \ || git diff --name-only HEAD~1 HEAD || true) echo "=== Changed files ===" echo "${CHANGED:-}" echo "=====================" - while IFS='=' read -r key pattern; do - [[ "${key}" == c-* ]] || continue + while IFS= read -r name; do + [[ -n "${name}" ]] || continue + pattern=$(yq -r "${section}.\"${name}\"" "${ROUTING}") if [ -z "${CHANGED}" ]; then result=false elif [[ "${MODE}" == "detect_all" ]]; then @@ -65,13 +70,13 @@ case "${MODE}" in result=false fi fi - json=$(echo "${json}" | jq --argjson v "${result}" '. + {"'"${key}"'": $v}') - echo " [${MODE}] ${key} = ${result} (pattern: ${pattern})" - done < <(env | sort) + json=$(echo "${json}" | jq --argjson v "${result}" '. + {"c-'"${name}"'": $v}') + echo " [${MODE}] c-${name} = ${result} (pattern: ${pattern})" + done < <(yq -r "${section} | keys | .[]" "${ROUTING}") ;; *) - echo "ERROR: Unknown mode '${MODE}'. Use str, bool, or detect." >&2 + echo "ERROR: Unknown mode '${MODE}'. Use str, bool, detect, or detect_all." >&2 exit 1 ;; esac diff --git a/.circleci/scripts/compute-workflow-conditions.sh b/.circleci/scripts/compute-workflow-conditions.sh index 7e76290474e..6e9667e121a 100755 --- a/.circleci/scripts/compute-workflow-conditions.sh +++ b/.circleci/scripts/compute-workflow-conditions.sh @@ -1,23 +1,29 @@ #!/usr/bin/env bash -# Workflow routing decision tree for the CircleCI setup pipeline. +# Workflow routing policy for the CircleCI setup pipeline. # -# Reads the change-detection / dispatch params already written to -# /tmp/pipeline-parameters.json by earlier steps and enables the matching -# c-run_* workflow flags based on the trigger source, branch, tag, and schedule. +# Decides WHICH continuation workflows run, based on the trigger source, branch, +# tag, schedule name, and the change-detection / dispatch params already written +# to /tmp/pipeline-parameters.json by earlier steps. The workflow lists +# themselves live in routing.yml — this script only holds the conditions. +# +# Each enabled workflow sets c-run_: true in the JSON, which maps 1:1 to +# "when: << pipeline.parameters.c-run_ >>" in a continuation config. # # Inputs (set by the config.yml step environment): # BRANCH, TRIGGER_SOURCE, TAG, SCHEDULE_NAME # # Helpers (workflow-helpers.sh): -# run foo → sets c-run_foo: true in JSON (maps 1:1 to -# "when: << pipeline.parameters.c-run_foo >>" in a continuation config) -# is_true x → checks if c-x is true in JSON (set by earlier steps) -# param x → reads raw value of c-x from JSON -# finalize → strips intermediate params, keeps only the listed whitelist + all c-run_* flags +# run enable workflows by literal name +# run_group enable the workflows listed under routing.yml sec.key +# is_true true if c-x is true in the JSON +# param raw value of c-x from the JSON +# finalize strip intermediate params, keep c-run_* + passthrough # # How to add a new workflow: -# 1. Add "c-run_your_workflow: {type: boolean, default: false}" in the relevant continuation config -# 2. Add "run your_workflow" in the appropriate branch below +# 1. Declare "c-run_: {type: boolean, default: false}" in a continuation +# config under .circleci/continue/. +# 2. Wire it in here (literal "run " for a one-off, or add it to the +# relevant routing.yml list and use run_group). set -euo pipefail # shellcheck disable=SC1091 # sourced helper resolved at runtime, not by shellcheck @@ -26,12 +32,9 @@ init_json case "${TRIGGER_SOURCE}" in - # Scheduled pipelines: map schedule name → workflows + # Scheduled pipelines: map schedule name -> workflows (routing.yml schedules). scheduled_pipeline) - case "${SCHEDULE_NAME}" in - build_four_hours) run scheduled_todo_issues scheduled_cannon_full_tests ;; - build_daily) run scheduled_preimage_reproducibility scheduled_stale_check scheduled_heavy_fuzz_tests scheduled_daily_tests circleci_schedule_trigger_check ;; - esac + run_group schedules "${SCHEDULE_NAME}" ;; # Webhook (push events) @@ -116,27 +119,29 @@ case "${TRIGGER_SOURCE}" in fi ;; - # API triggers: dispatch flags select workflows + # API triggers: dispatch flags select workflows (routing.yml api_dispatch). api) run release + # main_dispatch only fires for genuine API dispatches, not github-event triggers. if is_true main_dispatch && [[ "$(param github-event-type)" == "__not_set__" ]]; then - run main contracts_feature_tests + run_group api_dispatch main_dispatch fi - if is_true fault_proofs_dispatch; then run develop_fault_proofs; fi - if is_true kontrol_dispatch; then run develop_kontrol_tests; fi - if is_true cannon_full_test_dispatch; then run scheduled_cannon_full_tests; fi - if is_true reproducibility_dispatch; then run scheduled_preimage_reproducibility; fi - if is_true stale_check_dispatch; then run scheduled_stale_check; fi - if is_true heavy_fuzz_dispatch; then run scheduled_heavy_fuzz_tests; fi - if is_true publish_contract_artifacts_dispatch; then run publish_contract_artifacts; fi - if is_true l2_fork_test_dispatch; then run l2_fork_test; fi - if is_true rust_ci_dispatch; then run rust_ci; fi - if is_true rust_e2e_dispatch; then run rust_e2e_ci; fi + # Simple dispatch flags: each enables its workflows when the flag is set. + # main_dispatch and labeled_pr have bespoke conditions, handled separately. + for flag in $(yq -r '.api_dispatch | keys | .[]' "${ROUTING}"); do + # Keep this skip-list in sync with bespoke api_dispatch conditions. + case "${flag}" in + main_dispatch | labeled_pr) continue ;; + esac + if is_true "${flag}"; then + run_group api_dispatch "${flag}" + fi + done + # GitHub "pull_request labeled" event triggers issue-close automation. if [[ "$(param github-event-type)" == "pull_request" && "$(param github-event-action)" == "labeled" ]]; then - run close_issue + run_group api_dispatch labeled_pr fi ;; esac -# Params to forward to continuation configs (everything else is stripped) -finalize "c-default_docker_image,c-rust_base_image,c-base_image,c-github-event-base64,c-go-cache-version,c-publish_contract_artifacts_ref" +finalize diff --git a/.circleci/scripts/test-schedule-triggers.js b/.circleci/scripts/test-schedule-triggers.js index 82367820ed6..cddec565127 100755 --- a/.circleci/scripts/test-schedule-triggers.js +++ b/.circleci/scripts/test-schedule-triggers.js @@ -4,7 +4,7 @@ import { readdirSync } from "node:fs"; import path from "node:path"; const repoRoot = path.resolve(import.meta.dir, "../.."); -const routingScriptPath = path.join(repoRoot, ".circleci/scripts/compute-workflow-conditions.sh"); +const routingPath = path.join(repoRoot, ".circleci/routing.yml"); const continuationDir = path.join(repoRoot, ".circleci/continue"); const apiBase = process.env.CIRCLECI_API_BASE ?? "https://circleci.com/api/v2"; const projectSlug = @@ -40,28 +40,16 @@ async function yqText(expression, file) { return await $`yq -r ${expression} ${file}`.text(); } -async function extractDecisionTree() { - const decisionTree = await Bun.file(routingScriptPath).text(); - if (decisionTree.trim() === "") { - console.error(`ERROR: Could not read routing script ${routingScriptPath}`); - process.exit(1); - } - return decisionTree; -} - -function extractScheduleMappings(decisionTree) { - const scheduledCase = decisionTree.match(/scheduled_pipeline\)\s*\n([\s\S]*?)\n\s*;;/); - if (!scheduledCase) { - return []; - } - +// routing.yml is the source of truth for schedule -> workflow lists. +async function readScheduleMappings() { + const output = await yqText('.schedules | to_entries[] | .key + " " + (.value | join(" "))', routingPath); const mappings = []; - const armPattern = /^\s*([A-Za-z0-9_-]+)\)\s+run\s+([^;]+?)\s*;;/gm; - for (const match of scheduledCase[1].matchAll(armPattern)) { - mappings.push({ - scheduleName: match[1], - workflows: match[2].trim().split(/\s+/), - }); + for (const line of output.split("\n")) { + const [scheduleName, ...workflows] = line.trim().split(/\s+/).filter(Boolean); + if (scheduleName === undefined) { + continue; + } + mappings.push({ scheduleName, workflows }); } return mappings; } @@ -146,9 +134,9 @@ async function fetchScheduleNames(token) { console.error(`ERROR: CircleCI schedule API returned more than ${maxPages} pages`); process.exit(1); } -const mappings = extractScheduleMappings(await extractDecisionTree()); +const mappings = await readScheduleMappings(); if (mappings.length === 0) { - console.error(`ERROR: no scheduled_pipeline schedule mappings found in ${routingScriptPath}`); + console.error(`ERROR: no schedules defined in ${routingPath}`); process.exit(1); } diff --git a/.circleci/scripts/workflow-helpers.sh b/.circleci/scripts/workflow-helpers.sh index 16ba8c4e6dd..3e53b552229 100755 --- a/.circleci/scripts/workflow-helpers.sh +++ b/.circleci/scripts/workflow-helpers.sh @@ -1,16 +1,21 @@ #!/usr/bin/env bash -# Sourceable helper functions for compute-workflow-conditions.sh. Provides JSON -# plumbing so the routing script only contains the routing policy. +# Sourceable helper functions for the workflow routing policy in +# compute-workflow-conditions.sh. Provides JSON plumbing and routing.yml +# readers so the policy script only contains the routing logic and routing.yml +# only contains the data. # -# Usage: +# Usage (from compute-workflow-conditions.sh): # source .circleci/scripts/workflow-helpers.sh # init_json -# ...routing logic using run/param/is_true... +# ...routing logic using run/run_group/param/is_true... # finalize -# Overridable so tests can point at an isolated file instead of the pipeline's -# real params file. +# OUTPUT is overridable so tests can point at an isolated file instead of the +# pipeline's real params file. OUTPUT="${OUTPUT:-/tmp/pipeline-parameters.json}" +# routing.yml lives one directory up from this script (.circleci/routing.yml), +# resolved from the script location so it works regardless of the caller's cwd. +ROUTING="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/routing.yml" _json="" init_json() { @@ -24,6 +29,18 @@ run() { done } +# run_group
: enable every workflow listed under +# routing.yml's
.. A missing key enables nothing. +run_group() { + local section="${1}" key="${2}" + local wfs + wfs=$(yq -r ".${section}.\"${key}\"[]?" "${ROUTING}") + if [[ -n "${wfs}" ]]; then + # shellcheck disable=SC2086 # intentional word-splitting of the name list + run ${wfs} + fi +} + param() { echo "${_json}" | jq -r ".\"c-${1}\"" } @@ -32,10 +49,11 @@ is_true() { [[ "$(param "${1}")" == "true" ]] } +# Strip intermediate params, keeping only c-run_* flags and the passthrough +# params declared in routing.yml, then write the final JSON. finalize() { - local keep_params="${1:?finalize requires a comma-separated list of params to keep}" local jq_filter - jq_filter=$(echo "${keep_params}" | tr ',' '\n' | sed 's/.*/"&"/' | paste -sd',' -) + jq_filter=$(yq -r '.passthrough_params[]' "${ROUTING}" | sed 's/.*/"&"/' | paste -sd',' -) _json=$(echo "${_json}" | jq "with_entries(select( .key | startswith(\"c-run_\") or IN(${jq_filter}) diff --git a/docs/ai/ci-config-review.md b/docs/ai/ci-config-review.md index 5a6441dc8d6..c5b89ae0017 100644 --- a/docs/ai/ci-config-review.md +++ b/docs/ai/ci-config-review.md @@ -7,17 +7,22 @@ hide. For each changed file, walk the relevant items and look for the bad patter ## How CI is wired here - **`config.yml` is a setup pipeline** (`setup: true`). `prepare-continuation-config` - detects changed paths, runs a decision tree, merges the continuation fragments, - and continues the pipeline. Only workflows whose `c-run_*` flag the tree set to - `true` execute. + detects changed paths, runs the routing policy (`compute-workflow-conditions.sh`), + merges the continuation fragments, and continues the pipeline. Only workflows whose + `c-run_*` flag the policy set to `true` execute. +- **Routing is data + logic split**: `routing.yml` holds the declarative data + (schedule→workflows, API dispatch flag→workflows, change-detection patterns, + passthrough params); `compute-workflow-conditions.sh` holds the conditions that + decide which entries fire. Add a schedule/dispatch/pattern by editing `routing.yml`. - **The real config is merged from fragments** under `.circleci/continue/` (`helpers.yml` → `main.yml` → `rust-ci.yml` → `rust-e2e.yml`) by `merge-configs.sh`. **Merge is later-wins**: a key (job, command, anchor) redefined in a later fragment silently overrides the earlier one. -- **Change detection**: `collect-params.sh` turns `c-*` env vars into params; - `detect` is true if *any* changed file matches the regex, `detect_all` only if - *every* file matches. `workflow-helpers.sh` sets the `c-run_*` flags; - `test-decision-tree.sh` asserts the tree. +- **Change detection**: `collect-params.sh str`/`bool` turn `c-*` env vars into + params; `detect`/`detect_all` match the `routing.yml` change patterns against the + changed files (`detect` true if *any* file matches, `detect_all` only if *every* + file matches). `workflow-helpers.sh` sets the `c-run_*` flags; + `test-decision-tree.sh` asserts the routing policy. - **The gate**: the GitHub `enforce-ci-checks-develop` ruleset requires exactly four checks — `ci-gate`, `required-contracts-ci`, `required-rust-ci`, `required-rust-e2e`. These are fan-in jobs (no work, just `requires:`). A merge