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
51 changes: 51 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,59 @@ concurrency:
cancel-in-progress: true

jobs:
pr-scope:
name: Verify documentation-only PR scope (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: Read all changed PR paths without checkout or Windows
uses: actions/github-script@v7
with:
script: |
// Fail closed on missing/ambiguous/renamed inputs and any API/job error.
let needsWindows = true;
try {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100
});
const documentationOnly = files.length > 0 && files.length < 3000 &&
files.every(({ filename, status, previous_filename }) => {
const allowedPath = filename === 'README.md' ||
(filename.startsWith('docs/') && filename.endsWith('.md') &&
filename !== 'docs/INTEROPERABILITY_REFERENCE_CONTRACT.md');
// Renamed and deleted documents can conceal source removals.
return allowedPath && (status === 'added' || status === 'modified') &&
!previous_filename;
});
needsWindows = !documentationOnly;
core.info(`Changed paths=${files.length}, Windows required=${needsWindows}`);
} catch (error) {
core.warning(`Cannot certify docs-only PR: ${error.message}; full Windows required.`);
}
core.setOutput('needs_windows', needsWindows ? 'true' : 'false');

build-windows:
name: Build, test, validate and package Windows application
needs: [pr-scope]
# The classifier may fail before emitting outputs. Anything except an
# explicit verified 'false' still triggers the full canonical Windows job.
# Push/manual do not run pr-scope, but MUST run canonical Windows proof.
if: >-
${{
always() &&
(github.event_name != 'pull_request' ||
needs.pr-scope.result != 'success' ||
needs.pr-scope.outputs.needs_windows != 'false')
}}
runs-on: windows-latest
steps:
- name: Checkout exact triggering ARSAS revision
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/smart-discovery-capture-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Smart Discovery Field Capture Build

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened, ready_for_review, labeled]
paths:
# Fast PR routing: only Smart Discovery authority/dependencies trigger this gate.
- "Services/NativeIec61850Client*.cs"
Expand Down Expand Up @@ -48,7 +48,9 @@ jobs:
if: >-
${{
always() &&
(github.event_name != 'pull_request' || !github.event.pull_request.draft) &&
(github.event_name != 'pull_request' ||
(!github.event.pull_request.draft &&
contains(github.event.pull_request.labels.*.name, 'ci-field-capture'))) &&
(github.event_name != 'workflow_dispatch' ||
needs.manual-regression.result == 'success')
}}
Expand Down
31 changes: 31 additions & 0 deletions docs/audits/CI_P4_D19_FAILCLOSED_IMPLEMENTATION_2026-10-27.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# D19: guarded CI P4 on the integrated RC (code-only candidate)
**Date:** 2026-10-27 (Asia/Jakarta)
**Candidate branch:** `ci/p4-failclosed-optimized-gates-d19-20261027` based on exact integrated RC `817c3910e8223fa2ac531a4426ada5d2d4bbbe50` (not `main`, not stable `v1.6.42`).
**Issue:** [#542](https://github.com/masarray/arsas/issues/542) / original historical [#653](https://github.com/masarray/arsas/pull/653).

## Purpose
Make the canonical Windows build skip **only** when GitHub's full API PR file inventory proves that every changed file is a newly added or modified `README.md` or `docs/**/*.md`, excluding `docs/INTEROPERABILITY_REFERENCE_CONTRACT.md`. All mixed, executable, test, script, workflow, engine, SCL-contract, renamed, removed, unknown, empty, API-error and truncated input means **run full Windows**. This is stricter than historical #653, because it refuses renamed files and uses the fail-closed consumer condition when the classifier job itself fails.

## Surgical changes
- `.github/workflows/build.yml`: `pr-scope` lightweight Ubuntu GitHub API read-only job (no checkout, no build); retains exactly one **existing** `build-windows` job and its exact-SHA package/test steps and artifact names. `build-windows` is skipped **only** if the classifier job completed successfully and returned `needs_windows=false` for a PR. The job runs on push/manual, on errors and missing outputs. GitHub marks skipped conditional jobs as successful: this never provides a sealed package nor physical qualification for docs-only SHAs.
- `.github/workflows/smart-discovery-capture-build.yml`: extra Field Capture Windows packaging is *opt-in* only for a **nondraft** PR explicitly labeled `ci-field-capture`; adding label triggers `labeled` event, with existing path restrictions. Manual `workflow_dispatch` still requires reusable `manual-regression` exact-SHA proof; unchanged Windows job name, source acceptance and cancellation behavior.
- `scripts/test-ci-workflows.py`: two focused static workflow policy regressions; existing P0 budget/inventory tests remain.
- This document.

## Security and merge conditions
1. This is code-only on an isolated branch. No PR has been opened automatically to avoid an unqualified multi-workflow CI fan-out; **no full Windows or physical IED FAT executed by D19**. Existing RC green CI runs predate this modification and are not evidence for this candidate.
2. Review branch-protection/ruleset required check names: GitHub's job-level skip succeeds, whereas *workflow* path-filter skips can remain pending. No change to top-level workflow trigger or required check job name. Required checks + absence of artifact for docs-only SHAs must be explicitly checked before promotion.
3. Validate true GitHub events: (a) docs-only modified PR -> cheap classifier, build skipped; (b) source/mixed/rename/contract PR -> full Windows; (c) API failure/incomplete/truncated result or failed preflight job -> full Windows; (d) manual and main push -> full Windows; (e) unlabeled Smart Discovery PR -> no extra Field Capture build; (f) label `ci-field-capture` -> field capture runs; (g) manual capture -> reusable exact-SHA proof first.
4. Preserve all canonical release/physical gates, explicit engine locks, safety #753–#756, Write #726/#716, SG7 #688, GUI #732, and RC #727/#729. Do not promote without checking real controls and branch protection.

## Offline preflight (D19)
Syntactic anchors and logic were inspected against original RC files; a nine-case conservative PR classifier truth table was evaluated in JS before the commit (docs/readme, mixed source, protected contract, source rename, removal, empty, GitHub 3000 cap, API exception). The Python regression methods are **authored but not executed on GitHub Actions** until an intentionally approved qualification run.

**No need to close #653 or #542 yet**: original issue should stay pending until on-run scenario verification, runner-minute comparison, and policy acceptance. Old PR #653 diverged and is not merged byte-for-byte.

### D19 execution evidence (strictly offline)
- The inlined `github-script` decision body was reproduced and run in Node.js `vm` with mocked GitHub API and output callbacks: **11 scenarios passed** (README, multiple docs, mixed source, protected contract, source->docs rename, docs->docs rename, removal, empty list, workflow source, 3,000-file truncation, API exception).
- The exact Windows admission predicate was checked for **six** push/manual/PR classifier-result and output cases. In particular **classifier failure and missing output always execute Windows**, while explicit `success` + `false` on a PR is the only skip.
- The Field Capture predicate was checked for **seven** scenarios: unlabeled PR, labeled PR, labeled draft, irrelevant label, manually dispatched regression successful/failed/missing.
- **24 local decision scenarios passed.** These are function/decision simulations, **not** proof that a GitHub-hosted workflow parsed and executed or that `scripts/test-ci-workflows.py` completed on a runner.
- Direct branch-protection read `GET /repos/masarray/arsas/branches/main/protection` failed with **403: Resource not accessible by integration**. Repository rulesets listing returned empty, which is not proof that there are no configured required checks. Owner must verify in GitHub settings before promotion. No PR or CI run was created.
172 changes: 172 additions & 0 deletions scripts/test-ci-workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,5 +418,177 @@ def matches(changed: str) -> bool:
):
self.assertFalse(matches(path), path)

def test_p4_docs_only_pr_preflight_never_fails_open(self):
"""Exact verified doc-only PRs may skip Windows; any doubt runs Windows."""
repo = Path(__file__).resolve().parents[1]
workflow = (repo / '.github/workflows/build.yml').read_text(encoding='utf-8')
scope = workflow.split(' pr-scope:\n', 1)[1].split(' build-windows:\n', 1)[0]
build = workflow.split(' build-windows:\n', 1)[1]
for contract in (
"if: github.event_name == 'pull_request'",
'runs-on: ubuntu-latest',
'pull-requests: read',
'uses: actions/github-script@v7',
'github.paginate(github.rest.pulls.listFiles',
'per_page: 100',
'files.length > 0 && files.length < 3000',
"filename === 'README.md'",
"filename.startsWith('docs/')",
"filename.endsWith('.md')",
"filename !== 'docs/INTEROPERABILITY_REFERENCE_CONTRACT.md'",
"status === 'added' || status === 'modified'",
'!previous_filename',
'catch (error)',
"core.setOutput('needs_windows', needsWindows ? 'true' : 'false')",
):
self.assertIn(contract, scope)
self.assertNotIn('actions/checkout@', scope)
self.assertNotIn('dotnet ', scope)
self.assertIn('needs: [pr-scope]', build)
self.assertIn('always() &&', build)
self.assertIn("github.event_name != 'pull_request'", build)
self.assertIn("needs.pr-scope.result != 'success'", build)
self.assertIn("needs.pr-scope.outputs.needs_windows != 'false'", build)
self.assertNotIn("needs.pr-scope.outputs.needs_windows == 'true'", build)
self.assertEqual(workflow.count(' build-windows:\n'), 1)
self.assertIn('name: Upload sealed Windows package input', build)
self.assertIn('Upload directly downloadable portable EXE (no ZIP)', build)
self.assertIn('archive: false', build)
self.assertIn('ref: ${{ github.sha }}', build)
self.assertIn(' push:\n branches: [ main ]', workflow)
self.assertIn(' workflow_dispatch:', workflow)

def test_p4_capture_requires_opt_in_and_retains_manual_exact_sha_proof(self):
"""An unlabeled Smart Discovery PR must not spend a 2nd Windows runner."""
repo = Path(__file__).resolve().parents[1]
src = (repo / '.github/workflows/smart-discovery-capture-build.yml').read_text(encoding='utf-8')
self.assertIn('types: [opened, synchronize, reopened, ready_for_review, labeled]', src)
self.assertIn('contains(github.event.pull_request.labels.*.name, \'ci-field-capture\')', src)
self.assertIn('!github.event.pull_request.draft', src)
self.assertIn("github.event_name == 'workflow_dispatch'", src)
self.assertIn("needs.manual-regression.result == 'success'", src)
self.assertIn(' needs: [manual-regression]', src)
self.assertIn(' source_sha: ${{ github.sha }}', src)
self.assertIn(' shadow_mode: true', src)
self.assertIn(' runs-on: windows-latest', src)
self.assertIn('cancel-in-progress: true', src)
self.assertIn('name: Build smart-discovery portable field capture', src)


def test_p4_execute_actual_api_script_and_job_conditions(self):
"""Execute extracted P4 code and job if-expressions on Node, no GitHub CI."""
import shutil
import textwrap

self.assertIsNotNone(shutil.which("node"), "Ubuntu CI P0 needs Node.js")
repo = Path(__file__).resolve().parents[1]
build = (repo / ".github/workflows/build.yml").read_text(encoding="utf-8")
capture = (repo / ".github/workflows/smart-discovery-capture-build.yml").read_text(encoding="utf-8")
scope = build.split(" pr-scope:\n", 1)[1].split(" build-windows:\n", 1)[0]
js_script = textwrap.dedent(scope.split(" script: |\n", 1)[1]).strip()

def condition(source, job, after):
body = source.split(" " + job + ":\n", 1)[1]
raw = body.split(" if: >-\n", 1)[1].split(" " + after + ":", 1)[0]
raw = textwrap.dedent(raw).strip()
self.assertTrue(raw.startswith("$" + "{{") and raw.endswith("}}"))
return raw[3:-2].strip()

def changed(name, status="modified", **extra):
return dict(filename=name, status=status, **extra)

fixtures = {
"script": js_script,
"win": condition(build, "build-windows", "runs-on"),
"capture": condition(capture, "build-smart-capture", "name"),
"files": [
dict(name="readme", files=[changed("README.md")], expected="false"),
dict(name="docs", files=[changed("docs/A.md", "added"), changed("docs/B.md")], expected="false"),
dict(name="source", files=[changed("Models/MonitorModels.cs")], expected="true"),
dict(name="mixed", files=[changed("README.md"), changed("Services/Runtime.cs")], expected="true"),
dict(name="protected", files=[changed("docs/INTEROPERABILITY_REFERENCE_CONTRACT.md")], expected="true"),
dict(name="rename_source", files=[changed("docs/A.md", "renamed", previous_filename="Services/Main.cs")], expected="true"),
dict(name="rename_docs", files=[changed("docs/A.md", "renamed", previous_filename="docs/B.md")], expected="true"),
dict(name="removed", files=[changed("docs/A.md", "removed")], expected="true"),
dict(name="empty", files=[], expected="true"),
dict(name="workflow", files=[changed(".github/workflows/build.yml")], expected="true"),
dict(name="truncated", files=[changed("docs/" + str(i) + ".md") for i in range(3000)], expected="true"),
dict(name="api_failure", files=[changed("README.md")], fail=True, expected="true"),
],
"windows": [
dict(name="docs_ok", event="pull_request", result="success", output="false", expected=False),
dict(name="source", event="pull_request", result="success", output="true", expected=True),
dict(name="no_output", event="pull_request", result="success", output="", expected=True),
dict(name="failed", event="pull_request", result="failure", output="", expected=True),
dict(name="skipped", event="pull_request", result="skipped", output="", expected=True),
dict(name="push", event="push", result="skipped", output="", expected=True),
dict(name="manual", event="workflow_dispatch", result="skipped", output="", expected=True),
],
"field": [
dict(name="unlabeled", event="pull_request", draft=False, labels=[], manual="skipped", expected=False),
dict(name="irrelevant", event="pull_request", draft=False, labels=["other"], manual="skipped", expected=False),
dict(name="labeled", event="pull_request", draft=False, labels=["ci-field-capture"], manual="skipped", expected=True),
dict(name="draft", event="pull_request", draft=True, labels=["ci-field-capture"], manual="skipped", expected=False),
dict(name="manual_ok", event="workflow_dispatch", manual="success", expected=True),
dict(name="manual_fail", event="workflow_dispatch", manual="failure", expected=False),
dict(name="manual_missing", event="workflow_dispatch", manual="skipped", expected=False),
],
}

runner = r'''
const fs = require("node:fs"), vm = require("node:vm");
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const equal = (got, want, name) => {
if (got !== want) throw Error(name + ": got=" + got + " expected=" + want);
};
(async () => {
const classify = vm.runInNewContext("(async (github, context, core) => {\n" + input.script + "\n})");
for (const c of input.files) {
const emitted = {};
const github = {
rest: {pulls: {listFiles() {}}},
paginate: async (method, args) => {
if (args.per_page !== 100 || args.pull_number !== 123) throw Error("bad pagination");
if (c.fail) throw Error("synthetic API failure");
return c.files;
}
};
const context = {repo: {owner: "owner", repo: "arsas"}, issue: {number: 123}};
const core = {info() {}, warning() {}, setOutput(k,v) {emitted[k] = v;}};
await classify(github, context, core);
equal(emitted.needs_windows, c.expected, "classification/" + c.name);
}
const windows = input.win.replaceAll("needs.pr-scope", "needs.prScope");
const capture = input.capture.replaceAll("needs.manual-regression", "needs.manualRegression")
.replaceAll("github.event.pull_request.labels.*.name",
"github.event.pull_request.labels.map(x => x.name)");
for (const c of input.windows) {
const context = {
always: () => true, github: {event_name: c.event},
needs: {prScope: {result: c.result, outputs: {needs_windows: c.output}}}
};
equal(Boolean(vm.runInNewContext("(" + windows + ")", context)), c.expected, "Windows/" + c.name);
}
for (const c of input.field) {
const context = {
always: () => true,
github: {event_name: c.event, event: {pull_request: {
draft: c.draft, labels: (c.labels || []).map(name => ({name}))
}}},
contains: (values, target) => values.includes(target),
needs: {manualRegression: {result: c.manual}}
};
equal(Boolean(vm.runInNewContext("(" + capture + ")", context)), c.expected, "Field/" + c.name);
}
console.log("CI-P4 actual-source policy expressions: 26/26 PASS");
})().catch(e => { console.error(e.stack || e); process.exitCode=1; });
'''
completed = subprocess.run(
["node", "-e", runner], input=json.dumps(fixtures), text=True,
capture_output=True, timeout=20, check=False
)
self.assertEqual(0, completed.returncode, completed.stdout + "\n" + completed.stderr)
self.assertIn("26/26 PASS", completed.stdout)

if __name__ == "__main__":
unittest.main()
Loading