diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 770f9fc59..fa82bec12 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -2,6 +2,7 @@ name: ARStack Studio Qt on: push: + branches: [main] paths: - 'apps/arstack_studio/**' - 'include/ariec61850/sampled_values/**' @@ -21,7 +22,7 @@ permissions: concurrency: group: arstack-studio-qt-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-launcher-parse: diff --git a/.github/workflows/arstack-studio-rc2-policy.yml b/.github/workflows/arstack-studio-rc2-policy.yml index 9a16d418f..03f980159 100644 --- a/.github/workflows/arstack-studio-rc2-policy.yml +++ b/.github/workflows/arstack-studio-rc2-policy.yml @@ -9,6 +9,7 @@ on: - 'docs/SMV_STUDIO_RC2_ACCEPTANCE.md' - '.github/workflows/arstack-studio-rc2-policy.yml' push: + branches: [main] paths: - 'apps/arstack_studio/src/Rc2Acceptance.cpp' - 'apps/arstack_studio/rc2/**' @@ -21,7 +22,7 @@ permissions: concurrency: group: arstack-studio-rc2-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-policy: diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 04d93998e..9639712fe 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -2,6 +2,7 @@ name: ARStack Studio Windows Lifecycle on: push: + branches: [main] paths: - 'apps/arstack_studio/**' - '.github/workflows/arstack-studio-windows-lifecycle.yml' @@ -15,7 +16,7 @@ permissions: concurrency: group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: close-exit-reopen: diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml index 2c7bd3d8e..d1638cf18 100644 --- a/.github/workflows/brcb-hard-profile-ci.yml +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -5,10 +5,26 @@ on: branches: - main pull_request: + paths: + - 'embedded/brcb_hard_profile/**' + - 'embedded/brcb_*.cpp' + - 'embedded/mms_brcb_*.cpp' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'tests/fixtures/scl/**' + - '.github/workflows/brcb-hard-profile-ci.yml' permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: brcb-hard-profile: name: BRCB Hard / ${{ matrix.compiler }} diff --git a/.github/workflows/ci-routing-policy.yml b/.github/workflows/ci-routing-policy.yml new file mode 100644 index 000000000..c17463caf --- /dev/null +++ b/.github/workflows/ci-routing-policy.yml @@ -0,0 +1,26 @@ +name: CI Routing Policy +on: + pull_request: + paths: + - '.github/workflows/**' + - 'scripts/check_ci_trigger_policy.py' + - 'docs/CI_WORKFLOW_ROUTING.md' + push: + branches: [main] + paths: + - '.github/workflows/**' + - 'scripts/check_ci_trigger_policy.py' + - 'docs/CI_WORKFLOW_ROUTING.md' +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + routing-contract: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + - name: Verify scoped PR workflow routing + run: python3 scripts/check_ci_trigger_policy.py diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml index 9623c7fed..74f78d5ad 100644 --- a/.github/workflows/control-interop-harness-ci.yml +++ b/.github/workflows/control-interop-harness-ci.yml @@ -5,11 +5,28 @@ on: branches: - "agent/phase-4d-c5-control-interoperability" pull_request: + paths: + - 'tools/control_interop_probe/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - '.github/workflows/control-interop-harness-ci.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-interop: name: Control Interop Harness / ${{ matrix.compiler }} diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 193f6e8e4..975c373de 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -4,11 +4,26 @@ on: push: branches: [main] pull_request: + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'scripts/**' + - 'fuzz/**' + - 'embedded/**' + - '.github/workflows/cpp-ci.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: linux: name: Linux / ${{ matrix.compiler }} diff --git a/.github/workflows/dynamic-rcb-trial-ci.yml b/.github/workflows/dynamic-rcb-trial-ci.yml index 7e20d3b91..3e3933d9b 100644 --- a/.github/workflows/dynamic-rcb-trial-ci.yml +++ b/.github/workflows/dynamic-rcb-trial-ci.yml @@ -4,11 +4,28 @@ on: push: branches: ["agent/phase-4d-d2-dynamic-rcb-live-trial"] pull_request: + paths: + - 'tools/dynamic_rcb_trial/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - '.github/workflows/dynamic-rcb-trial-ci.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: strategy: diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index e7520114e..54ef8eae4 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -5,10 +5,35 @@ on: branches: - main pull_request: + paths: + - 'embedded/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/goose/**' + - 'src/goose/**' + - 'include/ariec61850/sampled_values/**' + - 'src/sampled_values/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - '.github/workflows/embedded-ci.yml' permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: embedded-host-sim: name: Embedded host-sim / ${{ matrix.compiler }} diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 1b7c9c967..035a032b0 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -22,8 +22,8 @@ permissions: contents: read concurrency: - group: esp32p4-ptp-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: esp32p4-ptp-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: build-esp32p4-ptp: diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index f1909791d..fbd885134 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -35,8 +35,8 @@ permissions: contents: read concurrency: - group: esp32p4-smv-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: esp32p4-smv-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: ARSTACK_FIRMWARE_VERSION: '0.1.1' diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 6b9cbf498..cd660278c 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -2,6 +2,7 @@ name: IED Simulator Qt on: push: + branches: [main] paths: - 'apps/ied_simulator/**' - 'include/ariec61850/scl/**' @@ -41,6 +42,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build-and-smoke: runs-on: ubuntu-24.04 diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 5285cdf7c..da767ee20 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -1,19 +1,28 @@ name: IED Simulator Release Hardening on: + workflow_dispatch: push: + branches: [main] paths: - 'apps/ied_simulator/**' - 'assets/**' - '.github/workflows/ied-simulator-release-hardening.yml' pull_request: paths: - - 'apps/ied_simulator/**' + - 'apps/ied_simulator/packaging/**' + - 'apps/ied_simulator/CMakeLists.txt' + - 'apps/ied_simulator/src/main.cpp' + - 'assets/**' - '.github/workflows/ied-simulator-release-hardening.yml' permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: linux-release-soak: runs-on: ubuntu-24.04 diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 5fbd9d222..0ea016870 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -2,6 +2,7 @@ name: PTP Core CI on: push: + branches: [main] paths: - 'include/ariec61850/time_sync/**' - 'src/time_sync/**' @@ -39,6 +40,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: portable-ptp: strategy: diff --git a/.github/workflows/scl-assisted-connect-ci.yml b/.github/workflows/scl-assisted-connect-ci.yml index cd71df3e9..ce20a877c 100644 --- a/.github/workflows/scl-assisted-connect-ci.yml +++ b/.github/workflows/scl-assisted-connect-ci.yml @@ -2,6 +2,7 @@ name: SCL-Assisted Connect CI on: push: + branches: [main] paths: - 'include/ariec61850/mms/scl_assisted_connect.hpp' - 'include/ariec61850/mms/scl_association.hpp' @@ -37,8 +38,8 @@ on: - '.github/workflows/scl-assisted-connect-ci.yml' concurrency: - group: scl-assisted-connect-${{ github.ref }} - cancel-in-progress: true + group: scl-assisted-connect-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: linux-profile: diff --git a/.github/workflows/scl-assisted-workbench-ci.yml b/.github/workflows/scl-assisted-workbench-ci.yml index 5485105f6..bbe242e41 100644 --- a/.github/workflows/scl-assisted-workbench-ci.yml +++ b/.github/workflows/scl-assisted-workbench-ci.yml @@ -2,6 +2,7 @@ name: SCL-Assisted Workbench CI on: push: + branches: [main] paths: - 'apps/ied_simulator/src/MmsClientController.*' - 'apps/ied_simulator/src/MmsLiveTreeModel.*' @@ -38,8 +39,8 @@ permissions: contents: read concurrency: - group: scl-assisted-workbench-${{ github.ref }} - cancel-in-progress: true + group: scl-assisted-workbench-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: product-path: diff --git a/.github/workflows/security-evidence.yml b/.github/workflows/security-evidence.yml index 01c3dc1e0..ec69f2782 100644 --- a/.github/workflows/security-evidence.yml +++ b/.github/workflows/security-evidence.yml @@ -4,11 +4,24 @@ on: push: branches: [main] pull_request: + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'fuzz/**' + - '.github/workflows/security-evidence.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: sanitizers: name: Clang / ASan + UBSan diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index da4287d87..95038665f 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -2,6 +2,7 @@ name: SMV Injector GUI on: push: + branches: [main] paths: - 'apps/smv_injector_gui/**' - 'tools/smv_profile_inspect.cpp' @@ -27,6 +28,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: profile-bridge-smoke: runs-on: ubuntu-latest diff --git a/docs/CI_WORKFLOW_ROUTING.md b/docs/CI_WORKFLOW_ROUTING.md new file mode 100644 index 000000000..808ed79b3 --- /dev/null +++ b/docs/CI_WORKFLOW_ROUTING.md @@ -0,0 +1,25 @@ +# CI routing contract — ARStack61850 + +The CI contract is **risk-scoped PR checks, full integrated `main` checks, and manual/on-demand release packaging**. A new feature commit must never trigger unrelated domain matrices. + +## Pull request matrix + +| Changed area | Normal PR gate | Additional relevant gates | +| --- | --- | --- | +| `apps/ied_simulator/qml/**` / Workbench UI | IED Simulator Qt | SCL-assisted Workbench only for its specifically listed C++ model files | +| `apps/ied_simulator/packaging/**`, app CMake or entrypoint | IED Simulator Qt | IED Simulator Release Hardening | +| `include/**`, `src/**`, root `tests/**`, `tools/**` | C++ CI, Security and Evidence | Specialized profile gates matching the changed protocol | +| BRCB/RCB sources | C++ CI, Security and Evidence | BRCB Hard / Dynamic RCB where relevant | +| control interoperability sources | C++ CI, Security and Evidence | Control Interop Harness | +| embedded sources or embedded-facing protocol core | Embedded Profile CI | Other firmware / protocol-specific workflows through their own paths | + +- **PR**: path-scoped. Superseded PR heads are cancelled using workflow-specific concurrency groups. +- **Feature branch push**: no duplicate workflow run when an equivalent PR run already exists. The patched workflows run on push to `main` only (historic explicit special branches remain where applicable). +- **`main`**: preserve full integration CI where configured. It is not path-trimmed to optimize feature iteration. +- **Studio / PTP / legacy SMV GUI**: feature branch push does not duplicate the PR validation; push is scoped to `main`. +- **ESP32-P4 firmware / PTP lab**: feature-branch push still builds artifacts before a PR exists. Push and PR for the *same repository+branch* use a shared concurrency key, cancelling superseded feature runs without cancelling integrated `main` evidence. +- **Windows Workbench release package**: run automatically on release-impacting PRs and integrated `main` changes, or manually with `workflow_dispatch` for an explicit bench/release candidate. Normal QML-only PR iteration should not package an installer on every commit. +- **Before merging a release candidate**: explicitly run the Windows packaging/release acceptance workflow on the exact release candidate head, in addition to scoped PR CI. No hardware proof is implied by CI. +- **Workflow files themselves**: are included in their own path filters so trigger changes receive acceptance runs. The lightweight CI Routing Policy gate checks trigger invariants. + +Do not broaden unrelated PR `paths` to `apps/ied_simulator/**` or remove concurrency without updating `scripts/check_ci_trigger_policy.py`. Batch related changes into one commit before asking CI to validate; prefer failed-job reruns rather than repushing formatting edits. diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py new file mode 100644 index 000000000..89ad18054 --- /dev/null +++ b/scripts/check_ci_trigger_policy.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Fail fast if unrelated IEC 61850 workflow matrices flood a Workbench-only PR.""" +from pathlib import Path +import fnmatch +import re + +ROOT = Path(__file__).resolve().parents[1] / ".github" / "workflows" +NAME = [ + "brcb-hard-profile-ci.yml", + "control-interop-harness-ci.yml", + "cpp-ci.yml", + "dynamic-rcb-trial-ci.yml", + "embedded-ci.yml", + "security-evidence.yml", + "ied-simulator-qt.yml", + "ied-simulator-release-hardening.yml", + "scl-assisted-workbench-ci.yml", + "arstack-studio-qt.yml", + "scl-assisted-connect-ci.yml", + "arstack-studio-rc2-policy.yml", + "arstack-studio-windows-lifecycle.yml", + "ptp-core-ci.yml", + "smv-injector-gui.yml", +] +NON_GUI = set(NAME[:6]) +NORMAL_GUI = "apps/ied_simulator/qml/BrowserDataModelPane.qml" +MODEL_CPP = "apps/ied_simulator/src/MmsLiveTreeModel.cpp" +PACKAGING = "apps/ied_simulator/packaging/windows/ARStackIEC61850Workbench.nsi" + + +def event_body(source, event): + match = re.search(r"(?m)^ " + re.escape(event) + r":\s*\n", source) + if not match: + raise AssertionError(f"missing event: {event}") + rest = source[match.end():] + end = re.search(r"(?m)^ [a-z_]+:\s*(?:\n|$)|^[a-z][\w-]*:", rest) + return rest[:end.start()] if end else rest + + +def paths(source): + body = event_body(source, "pull_request") + assert re.search(r"(?m)^ paths:\s*$", body), "unscoped pull_request" + return re.findall(r"(?m)^ - ['\"]([^'\"]+)['\"]\s*$", body) + + +def matching(patterns, path): + return any(fnmatch.fnmatchcase(path, p) for p in patterns) + + +def main(): + sources = {name: (ROOT / name).read_text(encoding="utf-8") for name in NAME} + for name, source in sources.items(): + pr_paths = paths(source) + assert pr_paths, f"{name}: empty paths" + assert f".github/workflows/{name}" in pr_paths, f"{name}: workflow self-check missing" + assert re.search(r"(?m)^concurrency:", source), f"{name}: no concurrency" + assert "github.event.pull_request.number || github.ref" in source, f"{name}: wrong PR group" + assert "github.event_name == 'pull_request'" in source, f"{name}: stale runs not cancelled" + assert not matching(pr_paths, NORMAL_GUI) if name in NON_GUI else True, f"{name}: GUI triggers unrelated job" + for name in ["ied-simulator-qt.yml", "ied-simulator-release-hardening.yml", + "scl-assisted-workbench-ci.yml", "arstack-studio-qt.yml", + "scl-assisted-connect-ci.yml", + "arstack-studio-rc2-policy.yml", "arstack-studio-windows-lifecycle.yml", + "ptp-core-ci.yml", "smv-injector-gui.yml"]: + assert re.search(r"(?m)^ branches: \[main\]$", event_body(sources[name], "push")), name + assert matching(paths(sources["ied-simulator-qt.yml"]), NORMAL_GUI) + assert matching(paths(sources["scl-assisted-workbench-ci.yml"]), MODEL_CPP) + assert not matching(paths(sources["ied-simulator-release-hardening.yml"]), NORMAL_GUI) + assert matching(paths(sources["ied-simulator-release-hardening.yml"]), PACKAGING) + # Firmware branch builds are intentionally preserved for board testing, but + # one branch owns a shared push/PR concurrency key so only its latest + # firmware artifact run continues. + for name in ("esp32p4-smv-injector.yml", "esp32p4-ptp-lab.yml"): + fw = (ROOT / name).read_text(encoding="utf-8") + assert "github.event.pull_request.head.ref || github.ref_name" in fw, name + assert "github.event.pull_request.head.repo.full_name || github.repository" in fw, name + assert "github.ref != 'refs/heads/main'" in fw, name + print("CI_ROUTING_POLICY_PASS non_gui_excluded=6 duplicate_feature_push=0 pr_concurrency=15 firmware_branch_artifacts=preserved") + + +if __name__ == "__main__": + main()