diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f781495c..5369c02f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,8 +11,59 @@ concurrency: cancel-in-progress: true jobs: + pr-scope: + # No checkout or Windows runner. The GitHub API supplies only file names; + # unknown/too-large/unavailable comparisons always require full CI. + name: Classify documentation-only PR (fail closed) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + needs_windows: ${{ steps.files.outputs.needs_windows }} + steps: + - id: files + name: Decide whether canonical Windows proof is required + uses: actions/github-script@v7 + with: + script: | + let full = true; + try { + const changed = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100 + }); + // GitHub's PR-files endpoint is limited to 3000 files. + // An empty or truncated result can never authorize a skip. + if (changed.length > 0 && changed.length < 3000) { + const documentationOnly = changed.every(({filename}) => + filename === 'README.md' || + (filename.startsWith('docs/') && + filename.endsWith('.md') && + filename !== 'docs/INTEROPERABILITY_REFERENCE_CONTRACT.md') + ); + full = !documentationOnly; + } + core.info(`PR files=${changed.length}; canonical Windows required=${full}`); + } catch (error) { + core.warning(`PR files unavailable: ${error.message}; requiring full Windows CI`); + } + core.setOutput('needs_windows', full ? 'true' : 'false'); + build-windows: name: Build, test, validate and package Windows application + needs: [pr-scope] + # Push/manual always run. Only positively classified pure documentation + # skips the expensive Windows job; any error/unknown change runs full CI. + if: >- + ${{ + always() && + (github.event_name != 'pull_request' || + needs.pr-scope.outputs.needs_windows == 'true') + }} runs-on: windows-latest steps: - name: Checkout exact triggering ARSAS revision diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 95fe9fae7..1c641b0bd 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -2,6 +2,7 @@ name: Smart Discovery Field Capture Build on: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - "*.cs" - "**/*.cs" @@ -54,9 +55,15 @@ jobs: build-smart-capture: needs: [manual-regression] + # PRs already receive the canonical Build ARSAS portable + native proof. + # Extra Field Capture packaging/smoke is an explicit diagnostic request, + # not automatic work for every .cs/.xaml change. Keep the same check + # name (skipped status) and preserve complete workflow_dispatch. if: >- ${{ always() && + (github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'ci-field-capture')) && (github.event_name != 'workflow_dispatch' || needs.manual-regression.result == 'success') }} diff --git a/docs/audits/CI_P4_EFFICIENT_FAILCLOSED_PR_GATES_2026-10-21.md b/docs/audits/CI_P4_EFFICIENT_FAILCLOSED_PR_GATES_2026-10-21.md new file mode 100644 index 000000000..0bc0a556e --- /dev/null +++ b/docs/audits/CI_P4_EFFICIENT_FAILCLOSED_PR_GATES_2026-10-21.md @@ -0,0 +1,45 @@ +# CI P4 — Bounded Windows compute and explicit Field Capture (2026-10-21) + +## Scope and immutable baseline + +The user-accepted ARSAS application checkpoint remains **`5a33fb101026707caf6410ff097a569cfabf3939`** on `checkpoint/arsas-ctlmodel-glyph-command-stable-20261021`, documented in #651. CI optimization branch is separate, and modifies **no C#, XAML, native engine, IEC 61850 safety code, release workflow or ARIEC61850 lock**. + +Existing #426 CI budget and #430 canonical exact-SHA proof architecture remain in force. Do not shrink security/physical gates to chase CI green. + +## Measured baseline, not an estimate + +Example user-accepted PR #650 at exact SHA `5a33fb10` (GitHub Actions run `39499030618`): 12 parallel PR workflow runs. The canonical `Build ARSAS` completed successfully in about 477 elapsed seconds; an additional `Smart Discovery Field Capture Build` completed in about 491 elapsed seconds on another Windows runner, consuming a previously sealed canonical package. Concurrent SCL R7 and physical-readiness workflows were also running. Elapsed wall-clock time and billable runner minutes are not equivalent; these are **observed per-run runtimes**, not invoices. + +Some safeguards already exist: PR-number concurrency cancels superseded runs; the canonical `build.yml` produces pinned exact-source/engine TRX and sealed Windows package artifacts; several specialist workflows already reuse those artifacts. This change does **not** reinvent these proven features. + +## P4-A: inexpensive fail-closed PR scope (Build ARSAS) + +- New read-only `pr-scope` job uses `ubuntu-latest` + GitHub REST PR file list, **no checkout, .NET restore or packaging**. It emits `needs_windows=true|false`. +- Only a positively verified set of pure documentation paths (`README.md` and `docs/**/*.md`) may skip the canonical Windows job, **excluding** `docs/INTEROPERABILITY_REFERENCE_CONTRACT.md` because it carries release/IEC authority. +- All source code, tests, XAML, native/engine locks, safety evidence, scripts, assets, project configuration, workflows, unknown files or mixed PRs **always run** the full original Windows CI. +- Missing/empty PR file list, paginated result at or beyond GitHub's 3,000-file limit, permissions/API/network errors and unrecognized paths default to **full Windows CI**, never a skip. +- `push: main` and `workflow_dispatch` always run canonical Windows build/test/native smoke/package despite skipped preflight, preserving unchanged job name and build provenance. +- Note: a *document-only PR* cannot produce a portable artifact. Downstream users must not treat a skipped Windows job as package or physical authority. Specialist artifact verifiers remain fail-closed. + +## P4-B: avoid duplicate Field Capture Windows packaging + +- The specialist `Smart Discovery Field Capture Build` no longer starts an extra Windows packaging/smoke runner for every `*.cs`/XAML PR. Its canonical dependency is already provided by `Build ARSAS` on source-impacting PRs. +- Heavy `build-smart-capture` job now executes **only** after explicit PR label `ci-field-capture`, or via `workflow_dispatch`. +- `pull_request.types` includes `labeled` so engineers can request the specialist proof on the same PR without pushing an empty/noise commit. Updating a labeled PR still runs the specialist job on the new SHA. +- The existing manual exact-SHA reusable regression gate, canonical package digest/provenance verification, physical invariants, native CTest proof, portable smoke and artifact output are **unchanged** on opt-in paths. Status name retained; automatic unlabeled specialist job is intentionally skipped. +- This is no license to remove the specialist proof for actual smart discovery promotion or field-capture distribution: select the label/manual workflow before approval when that proof is needed. + +## Anti-regression CI contract + +- Workflows retain separate PR-scoped concurrency with `cancel-in-progress=true` and independent main/manual identities; no cross-PR cancellation and no alteration to artifact SHA acceptance. +- `scripts/test-ci-workflows.py` guards the fast-scope safe allowlist/fail-closed default, opt-in label semantics, dispatch path, existing canonical Windows job name and portable artifact ownership. CI P0 budget must remain green. +- Approval requires actual dry-run proof: a documentation-only PR skips `build-windows`; a source-impacting PR executes full Windows; a PR with `ci-field-capture` runs specialist capture; manually dispatched capture runs its full regression. CI jobs and check configurations are not a substitute for this scenario validation. +- Branch protection required checks are not readable from the GitHub connector (403); a path-filter or renamed job is **not** used. Existing job identifiers remain. Administrators must verify required-check configuration before promoting a workflow change to main. +- If the new PR is red, fail safe and preserve the accepted checkpoint. Do not disable a guard/physical authority to silence a pipeline. + +## Expected savings and limits + +- **Ordinary source-code PR:** eliminates **one** redundant ~491-second specialist Windows run per commit relative to PR #650, while preserving canonical Windows build and other IEC checks. This is an approximately **8.2-minute runner-occupancy reduction** on the measured case, **not** a guaranteed wall-clock speedup or GitHub bill credit. +- **Pure documentation PR:** avoids the entire ~477-second canonical Windows job, replaced by cheap Ubuntu scope. Exact savings depend on runner queues and repo-specific billing. +- **Explicit capture/physical-promotion PR:** extra specialist proof deliberately returns via `ci-field-capture` or manual dispatch. +- Additional historical SCL/physical-authority checks still overlap and may be consolidated only after exact-SHA parity is demonstrated, without weakening #651 or ARIEC native safety. diff --git a/scripts/test-ci-workflows.py b/scripts/test-ci-workflows.py index 1cb6bf9d9..814c78150 100644 --- a/scripts/test-ci-workflows.py +++ b/scripts/test-ci-workflows.py @@ -231,6 +231,52 @@ def test_p3i_main_and_manual_verification_runs_are_not_superseded(self): self.assertIn("cancel-in-progress: false", block) self.assertNotIn("github.ref", block) + def test_p4_docs_only_pr_preflight_preserves_fail_closed_build_authority(self): + """Docs may skip one Windows job; code, engine, CI, missing API and + main/manual must still retain canonical exact-SHA package authority.""" + repo = Path(__file__).resolve().parents[1] + source = (repo / ".github/workflows/build.yml").read_text(encoding="utf-8") + self.assertIn(" push:\n branches: [ main ]", source) + self.assertIn(" workflow_dispatch:", source) + self.assertIn(" pr-scope:", source) + self.assertIn("uses: actions/github-script@v7", source) + self.assertIn("github.paginate(github.rest.pulls.listFiles", source) + self.assertIn("pull-requests: read", source) + self.assertIn("changed.length > 0 && changed.length < 3000", source) + self.assertIn("filename === 'README.md'", source) + self.assertIn("filename.startsWith('docs/')", source) + self.assertIn("filename.endsWith('.md')", source) + self.assertIn("filename !== 'docs/INTEROPERABILITY_REFERENCE_CONTRACT.md'", source) + self.assertIn("catch (error)", source) + self.assertIn("core.setOutput('needs_windows', full ? 'true' : 'false')", source) + self.assertIn("needs.pr-scope.outputs.needs_windows == 'true'", source) + self.assertIn("github.event_name != 'pull_request'", source) + self.assertIn("always() &&", source) + self.assertEqual(source.count(" build-windows:\n"), 1) + self.assertIn("name: Build, test, validate and package Windows application", source) + self.assertIn("ref: ${{ github.sha }}", source) + self.assertIn("ARSAS-win-x64-portable-single-exe", source) + + def test_p4_field_capture_is_opt_in_but_manual_gate_is_intact(self): + repo = Path(__file__).resolve().parents[1] + source = (repo / ".github/workflows/smart-discovery-capture-build.yml").read_text( + encoding="utf-8" + ) + self.assertIn("types: [opened, synchronize, reopened, labeled]", source) + self.assertIn(" workflow_dispatch:", source) + self.assertIn("contains(github.event.pull_request.labels.*.name, 'ci-field-capture')", + source) + self.assertIn("github.event_name == 'workflow_dispatch'", source) + self.assertIn("needs.manual-regression.result == 'success'", source) + self.assertIn(" manual-regression:", source) + self.assertIn("shadow_mode: true", source) + self.assertIn(" build-smart-capture:", source) + self.assertIn("name: Build smart-discovery portable field capture", source) + self.assertIn("require-native-ctest-proof", source) + self.assertIn("Smoke test portable executable", source) + self.assertIn("Upload smart field-capture build", source) + self.assertIn("cancel-in-progress: true", source) + def test_budget_rejects_new_duplicate_build_cost(self): self.write( ".github/workflows/build.yml",