From 093fba9350858722d4d36b689f8a05852b66e547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D0=B1=D0=BE=D1=82?= Date: Tue, 13 Oct 2026 03:57:28 +0200 Subject: [PATCH 1/3] docs: define member gate propagation and CI scope [skip ci] --- docs/evidence/member-gates-20261013/README.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/evidence/member-gates-20261013/README.md diff --git a/docs/evidence/member-gates-20261013/README.md b/docs/evidence/member-gates-20261013/README.md new file mode 100644 index 0000000..707504f --- /dev/null +++ b/docs/evidence/member-gates-20261013/README.md @@ -0,0 +1,43 @@ +# web3d-dev: member board and release gates + +Objective: close the owner-side gaps described by the hub B-108/B-109 with +member-local, offline checks and witnessed negative plants. User authorization +is the 2026-10-13 autonomous family wave. Parent assigned this member explicitly. +No release, installation or hub pin update is in scope. + +Canonical base: [992fdf18a868acc780b3e3813e71bb7952bb6559](https://github.com/ssheleg/web3d-dev/commit/992fdf18a868acc780b3e3813e71bb7952bb6559). +Local main and other worktrees are preserved. Read CLAUDE.md, package.json, +.claude/agent-sync.json, docs/AGENT_SYNC.md, test/validate.py and both workflows. +The owner board is absent; the validator does not check board width/duplicates +or release fetch-depth/ancestry. The release workflow already has the runtime +ancestry guard; this work protects it in the member gate. + +## Requirements and plan + +| Requirement | Acceptance | +|---|---| +| GATE-01: create the owner board without fabricated historical closures | Rows cite current findings and this packet; width derives from each table header, IDs are unique | +| GATE-02: keep release ancestry and full-history checkout in the active job | Removal/comment/misplacement/disabled-step/ignored-failure plants are refused | +| GATE-03: prove the actual validator catches each defect | Run copied-tree subprocess controls before and after the implementation; prove the plant changed bytes and the clean control passed | +| GATE-04: keep the mechanism consistent across members | Byte-identical test/member_gates.py and test/member_gates_test.py from the Telegram pilot; only owner integration differs | +| GATE-05: align full-check scheduling with operator policy | 23:00 Europe/Warsaw schedule, workflow_call and explicit-candidate dispatch with recorded reason; no push/PR triggers | + +Sequence: negative baseline on the unchanged owner validator; portable helper +and owner board; local full gates once; independent review; commit/push a frozen +candidate. If a required hosted status is needed, coordinator authorizes one +explicit candidate run and binds its actual head SHA. Never dispatch per push. +Inspect reusable-workflow callers first; do not change deployment semantics +merely to remove an indirect caller. + +Task-pipeline owns delivery; make-skill the offline plugin gate; agent-sync local +advisory file ownership; evidence-docs the receipts. This test-only workflow +change has no product UX surface or provider call. The release guard is bounded +structural validation for the current workflow layout, not a general YAML/Bash +interpreter. No dependency is added to the member's stdlib-only offline gate. + +## Resume + +Next: record the observed negative baseline, apply the proven helper and run the +owner gates. Then obtain an independent exact-SHA review. Native/model behavior, +provider tests, release, installation and hub pin updates are NOT_RUN here. +Source request: [hub B-108/B-109](https://github.com/ssheleg/sshlg-skills/blob/ab377787/docs/evidence/backlog.md). From 1a3c0f00defda074b4d301299160b69eb6226490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D0=B1=D0=BE=D1=82?= Date: Tue, 13 Oct 2026 03:59:44 +0200 Subject: [PATCH 2/3] test: propagate owner gates and nightly candidate validation [skip ci] --- .github/workflows/validate.yml | 12 +- docs/evidence/backlog.md | 13 ++ docs/evidence/member-gates-20261013/README.md | 46 ++++++- test/member_gates.py | 127 ++++++++++++++++++ test/member_gates_test.py | 97 +++++++++++++ test/validate.py | 10 +- 6 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/backlog.md create mode 100644 test/member_gates.py create mode 100644 test/member_gates_test.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b4ea5b9..709968d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,8 +1,16 @@ name: validate on: - push: - pull_request: + # Full checks run nightly; an urgent, explicit candidate gets one recorded run. + schedule: + - cron: '0 23 * * *' + timezone: Europe/Warsaw + workflow_dispatch: + inputs: + candidate_reason: + description: 'Recorded urgent or explicit candidate exception; include task and frozen SHA' + required: true + type: string # Callable, so `release.yml` gates a publish on THIS suite rather than on a copy # of it. A second home for the gate is a gate that drifts at one of them. workflow_call: diff --git a/docs/evidence/backlog.md b/docs/evidence/backlog.md new file mode 100644 index 0000000..9b99cb5 --- /dev/null +++ b/docs/evidence/backlog.md @@ -0,0 +1,13 @@ +# web3d-dev evidence board + +This board starts with the 2026-10-13 owner-gate propagation. It does not invent +verdicts for earlier releases or replace their source evidence. IDs are local to +this board; the source requests are the hub's B-108 and B-109. + +| ID | Finding and acceptance | State | Evidence | +|---|---|---|---| +| GATE-01 | Own validator must reject missing boards, shifted columns and duplicate IDs using each table's header. | implemented on branch; independent review pending | [Task packet](member-gates-20261013/README.md), `test/member_gates_test.py` | +| GATE-02 | Own validator must reject removal of the release job's full-history checkout or unconditional ancestry failure guard. | implemented on branch; independent review pending | [Task packet](member-gates-20261013/README.md), `test/member_gates_test.py` | + +Remaining: independent review, owner integration, then the coordinator's separate +release and family-pin wave. No native/provider or past release verdict is claimed. diff --git a/docs/evidence/member-gates-20261013/README.md b/docs/evidence/member-gates-20261013/README.md index 707504f..4a1e1eb 100644 --- a/docs/evidence/member-gates-20261013/README.md +++ b/docs/evidence/member-gates-20261013/README.md @@ -37,7 +37,49 @@ interpreter. No dependency is added to the member's stdlib-only offline gate. ## Resume -Next: record the observed negative baseline, apply the proven helper and run the -owner gates. Then obtain an independent exact-SHA review. Native/model behavior, +Implementation and local gates are complete. Next: obtain an independent +exact-SHA review, then push a frozen candidate for coordinator integration. Native/model behavior, provider tests, release, installation and hub pin updates are NOT_RUN here. Source request: [hub B-108/B-109](https://github.com/ssheleg/sshlg-skills/blob/ab377787/docs/evidence/backlog.md). + + +## Observed verification + +- Before implementation, `python3 test/member_gates_test.py` against the original + owner validator reported **19 controls, 16 failures**, exit 1: every hostile + plant survived and all three valid controls passed. Each temporary copy was + owned by this harness and removed before the next case. +- `npm test`: exit 0, structural validator **15 checks**. +- `npm run test:negatives`: exit 0, **13 original defects** refused and + **19 new process controls, 0 failures**. These invoke the actual member + validator with clean baselines and changed-byte checks for each plant. +- `node test/installer_test.js`: exit 0, 11 cases; 11 scratch homes created and + removed, no real home installation. +- `claude plugin validate . --strict` and + `claude plugin validate plugins/web3d-dev --strict`: both exit 0. +- `git diff --check`: exit 0. +- `python3 test/evals_validate.py`: exit 0, 22 trigger cases and 6 scenarios. + +The common helper and control harness are byte-identical in the three member +worktrees (SHA-256 observed): + +- `test/member_gates.py`: `c519a300ea22e55eb0b9d70c68605e393c1f9949286bc8bdf8a676ee003e7193`. +- `test/member_gates_test.py`: `6d73a78dc33f0e3d98019282a7987458ddeba8dd45a98f1cf75d3eaa521a152a`. + +## Hosted validation scope + +Validation now has `schedule` at 23:00 Europe/Warsaw, `workflow_call` for release, +and `workflow_dispatch` with required `candidate_reason`. Push/PR triggers were +removed under the coordinator's explicit instruction. The workflow parsed with +PyYAML locally (exit 0), checking exactly these events, timezone and reason; +PyYAML is not introduced as an offline-gate dependency. The caller scan found +only `release.yml`; no Pages/deployment workflow triggers validation indirectly. +Release tags and the release job's dependency on validation remain unchanged. + +GitHub documents [timezone schedules](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) +and [manual dispatch refs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow), +read 2026-10-13. Nightly checks cover the default branch, not this unmerged +candidate. A coordinator-approved explicit candidate run must be tied to its +actual head SHA and genuine job result. No full hosted run has been dispatched +at this local verification point; no required status was fabricated. No release, +native/device/provider acceptance or hub pin change is claimed. diff --git a/test/member_gates.py b/test/member_gates.py new file mode 100644 index 0000000..7a8d8af --- /dev/null +++ b/test/member_gates.py @@ -0,0 +1,127 @@ +"""Portable owner gates, shared byte-for-byte by the FW02 member wave. + +Stdlib only. Release validation checks the current workflow's structural contract, +not arbitrary YAML or shell semantics. Unknown workflow layouts fail closed. +""" +from pathlib import Path +import re + + +def cells(line): + """Markdown pipes escaped by an odd number of backslashes are cell content.""" + result, start, escapes = [], 0, 0 + for index, char in enumerate(line): + if char == '|' and escapes % 2 == 0: + result.append(line[start:index].strip()) + start = index + 1 + escapes = escapes + 1 if char == '\\' else 0 + result.append(line[start:].strip()) + return result[1:-1] if not result[0] and not result[-1] else result + + +def label(value): + value = value.strip().strip('*` ') + link = re.fullmatch(r'\[([^\]]+)\]\([^)]*\)', value) + return (link.group(1) if link else value).strip('*` ') + + +def board_errors(text): + errors, seen, width, declares, count, fenced = [], {}, None, False, 0, False + for number, line in enumerate(text.splitlines(), 1): + line = line.strip() + if line.startswith(('```', '~~~')): + fenced = not fenced + width = None + continue + if fenced: + continue + if not line.startswith('|'): + width = None + continue + row = cells(line) + first = label(row[0]) if row else '' + if first.lower() in ('id', 'row'): + width, declares = len(row), first.lower() == 'id' + continue + if row and all(re.fullmatch(r':?-+:?', c) for c in row): + if width is not None and len(row) != width: + errors.append(f'{number}: board row width: separator differs from header') + continue + identifier = re.fullmatch(r'[A-Z]{1,4}-\d+[a-z]?', first) + if width is None: + if identifier: + errors.append(f'{number}: board row without ID header: {first}') + continue + if len(row) != width or not line.endswith('|'): + errors.append(f'{number}: board row width: {first} has {len(row)} cells; header declares {width}') + if not identifier: + errors.append(f'{number}: board invalid ID: {first!r}') + continue + if declares: + count += 1 + if first in seen: + errors.append(f'{number}: board duplicate ID {first}; first at {seen[first]}') + seen[first] = number + if not count: + errors.append('board has no declared rows') + return errors + + +def release_errors(text): + """Require the live release job's checkout and unconditional refusing guard. + + Bounded to this family's two-space job/six-space step layout; comments or + matching text in another job/step cannot satisfy this contract. + """ + errors = [] + active = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('#')) + job = re.search(r'^ release:\s*\n(?P.*?)(?=^ [\w-]+:|\Z)', active, re.M | re.S) + if not job: + return ['release job missing or unsupported layout'] + body = job.group('body') + if re.search(r'^ continue-on-error:\s*(?!false\s*(?:#.*)?$)\S', body, re.M): + errors.append('release ancestry failure ignored at job scope') + steps = re.split(r'(?=^ - )', body, flags=re.M)[1:] + full, checkouts, guards = [], [], [] + for index, step in enumerate(steps): + if re.search(r'^(?: - | )uses:\s*actions/checkout@', step, re.M): + checkouts.append(index) + options = re.search(r'^ with:\s*\n((?: [^\n]*\n?|\n)*)', step, re.M) + if (not re.search(r'^(?: - | )(?:if|continue-on-error):', step, re.M) + and options and re.search(r'^ fetch-depth:\s*[\'"]?0[\'"]?\s*(?:#.*)?$', options.group(1), re.M)): + full.append(index) + run = re.search(r'^ run:\s*\|[-+]?\s*\n((?: [^\n]*\n?|\n)*)', step, re.M) + if not run: + continue + shell = run.group(1) + guard = re.search(r'^ if ! git merge-base --is-ancestor HEAD [\'"]origin/\$DEF[\'"]; then\s*\n(?P.*?)(?=^ fi\s*$)', shell, re.M | re.S) + if not guard: + continue + guards.append(index) + if not re.search(r'^\s+exit 1\s*(?:#.*)?$', guard.group('failure'), re.M): + errors.append('release ancestry guard has no refusing exit') + if re.search(r'^(?: - | )if:', step, re.M): + errors.append('release ancestry step conditional') + if re.search(r'^(?: - | )continue-on-error:\s*(?!false\s*(?:#.*)?$)\S', step, re.M): + errors.append('release ancestry failure ignored at step scope') + if not guards: + errors.append('release ancestry guard missing from a live run block') + before = [i for i in checkouts if guards and i < guards[0]] + if not full or (guards and (not before or before[-1] not in full)): + errors.append('release full-history checkout missing before ancestry guard') + return errors + + +def check(root, fail): + root = Path(root) + for relative, inspect, missing in [ + ('docs/evidence/backlog.md', board_errors, 'board missing'), + ('.github/workflows/release.yml', release_errors, 'release workflow missing'), + ]: + try: + text = (root / relative).read_text(encoding='utf-8') + except OSError as exc: + fail(f'{relative}: {missing}: {exc.__class__.__name__}') + continue + for error in inspect(text): + fail(f'{relative}: {error}') diff --git a/test/member_gates_test.py b/test/member_gates_test.py new file mode 100644 index 0000000..d682638 --- /dev/null +++ b/test/member_gates_test.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Real validator plants for the portable member gate contract; no network.""" +from pathlib import Path +import os +import re +import shutil +import subprocess +import sys +import tempfile + +BOARD = '''# Board\n\n| ID | Finding | State |\n|---|---|---|\n| GATE-01 | Board grammar | open |\n| GATE-02 | Release guard | open |\n''' + + +def run(root): + env = dict(os.environ) + env.pop('TGDEV_ROOT', None) + p = subprocess.run([sys.executable, str(root / 'test/validate.py')], cwd=root, + env=env, text=True, capture_output=True) + return p.returncode, p.stdout + p.stderr + + +def self_test(root): + root = Path(root) + defects = [ + ('missing board', 'board', lambda _: None, 'board missing'), + ('pipe shifts a row', 'board', lambda t: t.replace('Board grammar', 'Board | grammar'), 'board row width'), + ('duplicate plain ID', 'board', lambda t: t.replace('GATE-02', 'GATE-01'), 'board duplicate ID'), + ('duplicate formatted ID', 'board', lambda t: t.replace('GATE-02', '[**GATE-01**](#proof)'), 'board duplicate ID'), + ('missing ID header', 'board', lambda t: t.replace('| ID |', '| Key |'), 'board row without ID header'), + ('no declared rows', 'board', lambda t: '\n'.join(l for l in t.splitlines() if 'GATE-' not in l), 'board has no declared rows'), + ('ancestry command removed', 'release', lambda t: t.replace('git merge-base --is-ancestor', 'git merge-base-removed --is-ancestor'), 'release ancestry guard'), + ('ancestry command commented', 'release', lambda t: t.replace('if ! git merge-base', '# if ! git merge-base'), 'release ancestry guard'), + ('shallow release checkout', 'release', lambda t: t.replace('fetch-depth: 0', 'fetch-depth: 1'), 'release full-history checkout'), + ('depth in a different job', 'release', lambda t: t.replace('fetch-depth: 0', 'fetch-depth: 1') + '\n decoy:\n steps:\n - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n', 'release full-history checkout'), + ('ancestry step disabled', 'release', lambda t: t.replace(' - name: The tag must be reachable', ' - if: false\n name: The tag must be reachable'), 'release ancestry step conditional'), + ('ancestry step failure ignored', 'release', lambda t: t.replace(' - name: The tag must be reachable', ' - continue-on-error: true\n name: The tag must be reachable'), 'release ancestry failure ignored'), + ('ancestry refusal removed', 'release', lambda t: re.sub(r'(if ! git merge-base[^\n]+\n.*?\n\s*)exit 1', r'\1exit 0', t, count=1, flags=re.S), 'release ancestry guard has no refusing exit'), + ('release job failure ignored', 'release', lambda t: t.replace(' release:\n', ' release:\n continue-on-error: true\n', 1), 'release ancestry failure ignored'), + ('checkout disabled', 'release', lambda t: t.replace(' - uses: actions/checkout@v4', ' - if: false\n uses: actions/checkout@v4', 1), 'release full-history checkout'), + ('checkout depth moved to env', 'release', lambda t: t.replace(' with:\n ref:', ' env:\n ref:', 1), 'release full-history checkout'), + ] + failures = [] + count = 0 + # One owned temporary parent; each small copy is removed before the next. + with tempfile.TemporaryDirectory(prefix='member-gates-') as temporary: + for label, kind, mutate, expect in defects: + dst = Path(temporary) / 'repo' + shutil.copytree(root, dst, ignore=shutil.ignore_patterns('.git', 'node_modules', '__pycache__', '.agent-sync')) + try: + board = dst / 'docs/evidence/backlog.md' + board.parent.mkdir(parents=True, exist_ok=True) + board.write_text(BOARD) + baseline, out = run(dst) + if baseline: + failures.append(f'{label}: clean fixture failed: {out}') + continue + target = board if kind == 'board' else dst / '.github/workflows/release.yml' + before = target.read_text() + after = mutate(before) + if before == after: + failures.append(f'{label}: plant changed nothing') + continue + if after is None: + target.unlink() + else: + target.write_text(after) + status, out = run(dst) + count += 1 + if not status or expect not in out: + failures.append(f'{label}: expected refusal {expect!r}; status={status}: {out}') + finally: + shutil.rmtree(dst) + for label, content in [ + ('escaped pipe', BOARD.replace('Board grammar', r'Board \| grammar')), + ('bold and linked IDs', BOARD.replace('GATE-01', '**GATE-01**').replace('GATE-02', '[GATE-02](#proof)')), + ('different table widths', BOARD + '\n## Second\n\n| ID | State |\n|---|---|\n| GATE-03 | open |\n'), + ]: + dst = Path(temporary) / 'repo' + shutil.copytree(root, dst, ignore=shutil.ignore_patterns('.git', 'node_modules', '__pycache__', '.agent-sync')) + try: + board = dst / 'docs/evidence/backlog.md' + board.parent.mkdir(parents=True, exist_ok=True) + board.write_text(content) + status, out = run(dst) + count += 1 + if status: + failures.append(f'{label}: valid control refused: {out}') + finally: + shutil.rmtree(dst) + for failure in failures: + print('FAIL: ' + failure) + print(f'member gates: {count} process controls, {len(failures)} failures') + return int(bool(failures)) + + +if __name__ == '__main__': + raise SystemExit(self_test(Path(__file__).resolve().parent.parent)) diff --git a/test/validate.py b/test/validate.py index 430a193..b742eff 100755 --- a/test/validate.py +++ b/test/validate.py @@ -467,9 +467,17 @@ def self_test() -> int: return 0 +@check +def check_member_board_and_release(): + from member_gates import check as member_check + member_check(ROOT, fail) + + def main(argv: list[str]) -> int: if "--self-test" in argv: - return self_test() + from member_gates_test import self_test as member_self_test + original = self_test() + return max(original, member_self_test(ROOT)) for fn in CHECKS: fn() if FAILURES: From baea7e14966149ce405be90fd5a630cf15222b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D0=B1=D0=BE=D1=82?= Date: Tue, 13 Oct 2026 04:18:24 +0200 Subject: [PATCH 3/3] Close structural gate bypasses and freeze reviewed candidate policy --- .github/workflows/validate.yml | 4 + docs/evidence/member-gates-20261013/README.md | 85 +++++++++++++++++-- test/member_gates.py | 68 +++++++++++++-- test/member_gates_test.py | 33 ++++++- 4 files changed, 171 insertions(+), 19 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 709968d..cb4f662 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,6 +1,10 @@ name: validate on: + # One explicit frozen candidate: draft -> ready after local gates and review. + # No opened/synchronize/reopened event and no per-push full suite. + pull_request: + types: [ready_for_review] # Full checks run nightly; an urgent, explicit candidate gets one recorded run. schedule: - cron: '0 23 * * *' diff --git a/docs/evidence/member-gates-20261013/README.md b/docs/evidence/member-gates-20261013/README.md index 4a1e1eb..6e408ae 100644 --- a/docs/evidence/member-gates-20261013/README.md +++ b/docs/evidence/member-gates-20261013/README.md @@ -20,7 +20,7 @@ ancestry guard; this work protects it in the member gate. | GATE-02: keep release ancestry and full-history checkout in the active job | Removal/comment/misplacement/disabled-step/ignored-failure plants are refused | | GATE-03: prove the actual validator catches each defect | Run copied-tree subprocess controls before and after the implementation; prove the plant changed bytes and the clean control passed | | GATE-04: keep the mechanism consistent across members | Byte-identical test/member_gates.py and test/member_gates_test.py from the Telegram pilot; only owner integration differs | -| GATE-05: align full-check scheduling with operator policy | 23:00 Europe/Warsaw schedule, workflow_call and explicit-candidate dispatch with recorded reason; no push/PR triggers | +| GATE-05: align full-check scheduling with operator policy | 23:00 Europe/Warsaw schedule, workflow_call and explicit-candidate dispatch with recorded reason; no push/automatic PR update triggers; ready_for_review only | Sequence: negative baseline on the unchanged owner validator; portable helper and owner board; local full gates once; independent review; commit/push a frozen @@ -38,7 +38,7 @@ interpreter. No dependency is added to the member's stdlib-only offline gate. ## Resume Implementation and local gates are complete. Next: obtain an independent -exact-SHA review, then push a frozen candidate for coordinator integration. Native/model behavior, +exact-SHA review, then move the pushed draft candidate to ready once for genuine required checks. Native/model behavior, provider tests, release, installation and hub pin updates are NOT_RUN here. Source request: [hub B-108/B-109](https://github.com/ssheleg/sshlg-skills/blob/ab377787/docs/evidence/backlog.md). @@ -63,14 +63,14 @@ Source request: [hub B-108/B-109](https://github.com/ssheleg/sshlg-skills/blob/a The common helper and control harness are byte-identical in the three member worktrees (SHA-256 observed): -- `test/member_gates.py`: `c519a300ea22e55eb0b9d70c68605e393c1f9949286bc8bdf8a676ee003e7193`. -- `test/member_gates_test.py`: `6d73a78dc33f0e3d98019282a7987458ddeba8dd45a98f1cf75d3eaa521a152a`. +- `test/member_gates.py`: `6d297aa8fe285e5aaaad67357829315e78ee014eb38ae6deec3d0753c8b94ff0`. +- `test/member_gates_test.py`: `b3ef7e6dc667f6b26b481c108443fdf1b6945a9133ab488a75b7fcd2e99c89e0`. ## Hosted validation scope Validation now has `schedule` at 23:00 Europe/Warsaw, `workflow_call` for release, -and `workflow_dispatch` with required `candidate_reason`. Push/PR triggers were -removed under the coordinator's explicit instruction. The workflow parsed with +and `workflow_dispatch` with required `candidate_reason`. Push and automatic PR opened/synchronize triggers were removed under the +coordinator's explicit instruction; ready_for_review is the only PR event. The workflow parsed with PyYAML locally (exit 0), checking exactly these events, timezone and reason; PyYAML is not introduced as an offline-gate dependency. The caller scan found only `release.yml`; no Pages/deployment workflow triggers validation indirectly. @@ -83,3 +83,76 @@ candidate. A coordinator-approved explicit candidate run must be tied to its actual head SHA and genuine job result. No full hosted run has been dispatched at this local verification point; no required status was fabricated. No release, native/device/provider acceptance or hub pin change is claimed. + + +## Review correction and final bounded contract + +Self-review reproduced an accepted duplicate release job and successful exit +before ancestry, then a successful exit inside the refusal branch. Independent +review by the hub coordinator reproduced quoted mapping keys overriding release, +run, fetch-depth and steps; a Row table silently escaped declaration uniqueness; +and missing/late Markdown separators passed. These accepting cases preceded +the corresponding correction. The first shell-override regression was also red +(26 process controls, 1 failure) before the step-leading shell property was fixed. + +A final merge-key plant (`<<: {continue-on-error: true}`) initially survived +the real validator: 42 process controls, one failure. Merge keys now fail closed +before structural inspection. + +The final common helper requires ID declaration tables and their immediate, +matching Markdown separator. Row reference ledgers are explicitly unsupported +in these new boards; they are not silently treated as declarations or ignored. +It reads each table's own width and rejects duplicate IDs across declarations. + +Release validation is intentionally bounded to the current plain mapping keys, +two-space jobs and six-space steps. Quoted keys, YAML merge keys, duplicate jobs/steps/run/checkout +options and depths, unknown defaults, conditional or ignored ancestry, and +nonstandard shell setup fail closed. The supported setup is set-eu, resolve the +actual default branch, fetch it, then the ancestry condition; the refusal branch +permits diagnostic echo commands followed by exit 1. This is a maintainable +structural contract, not a general YAML parser or arbitrary shell verifier. +Changing that grammar requires explicit review and new controls. Valid escaped +pipes, bold/linked IDs, differing table widths and explicit false failure-ignore +flags remain accepted. + +The final harness has **42 process controls: 38 hostile plants and four valid +controls**. All copies use the same helper and harness bytes. The last local +command results are recorded below; older 18/19/26-control results above are +intermediate observations, not the final result. + +## Final candidate CI policy + +Only `pull_request.types: [ready_for_review]` triggers a candidate suite. The PR +is created as draft, independently reviewed and locally gated, then moved to +ready once at its frozen head. No push/opened/reopened/synchronize trigger was +added. The 23:00 Europe/Warsaw schedule and release workflow_call remain. +workflow_dispatch with required candidate_reason is retained only for standalone +diagnostics; it does not satisfy PR required checks. This corrects the earlier +candidate-dispatch design above. Job bodies are unchanged. + +GitHub's [required-check event contract](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks#checks-from-some-workflow-jobs-are-not-evaluated), +read 2026-10-13, explicitly excludes workflow_dispatch jobs from PR admission. +The coordinator verified that behavior during this wave. A check must be a real +eligible-event job at the candidate head or its matching test merge commit; +there is no synthetic status, branch-protection bypass or release authorization. + +## Final local receipt + +`npm test && npm run test:negatives && git diff --check` completed with +exit 0 after the merge-key correction, sequentially across the three members. +The negative gate refused 13 original defects and passed all 42 common +process controls (38 hostile plants, four valid controls). Workflow parsing +assertions also passed: exactly four events, ready_for_review only for PRs, +23:00 Europe/Warsaw, required candidate_reason, and job bodies equal to the +canonical-main baseline. Earlier installer and strict-manifest checks above +remain applicable: these files did not change during parser review. + +Final source SHA-256: + +- `test/member_gates.py`: `6d297aa8fe285e5aaaad67357829315e78ee014eb38ae6deec3d0753c8b94ff0`. +- `test/member_gates_test.py`: `b3ef7e6dc667f6b26b481c108443fdf1b6945a9133ab488a75b7fcd2e99c89e0`. + +Independent final-commit approval and hosted PR admission remain pending at +this source receipt. Next: verify this exact commit, create or retain a draft +PR, then promote the frozen candidate once and watch the genuine check jobs. +No merge, release or hub pin update is authorized by this receipt. diff --git a/test/member_gates.py b/test/member_gates.py index 7a8d8af..01796c4 100644 --- a/test/member_gates.py +++ b/test/member_gates.py @@ -5,6 +5,7 @@ """ from pathlib import Path import re +import shlex def cells(line): @@ -27,8 +28,17 @@ def label(value): def board_errors(text): errors, seen, width, declares, count, fenced = [], {}, None, False, 0, False + pending = None for number, line in enumerate(text.splitlines(), 1): line = line.strip() + row = cells(line) if line.startswith('|') else [] + separator = bool(row) and all(re.fullmatch(r':?-{3,}:?', c) for c in row) + if pending is not None: + if separator and len(row) == width and line.endswith('|'): + pending = None + continue + errors.append(f'{pending}: board header needs an immediate matching separator') + pending = None if line.startswith(('```', '~~~')): fenced = not fenced width = None @@ -38,14 +48,17 @@ def board_errors(text): if not line.startswith('|'): width = None continue - row = cells(line) first = label(row[0]) if row else '' - if first.lower() in ('id', 'row'): - width, declares = len(row), first.lower() == 'id' + if first.lower() == 'row': + errors.append(f'{number}: board Row header unsupported; use ID for declarations') + width = None + continue + if first.lower() == 'id': + width, declares, pending = len(row), True, number continue - if row and all(re.fullmatch(r':?-+:?', c) for c in row): - if width is not None and len(row) != width: - errors.append(f'{number}: board row width: separator differs from header') + if separator: + if width is not None: + errors.append(f'{number}: board misplaced separator') continue identifier = re.fullmatch(r'[A-Z]{1,4}-\d+[a-z]?', first) if width is None: @@ -62,6 +75,8 @@ def board_errors(text): if first in seen: errors.append(f'{number}: board duplicate ID {first}; first at {seen[first]}') seen[first] = number + if pending is not None: + errors.append(f'{pending}: board header needs an immediate matching separator') if not count: errors.append('board has no declared rows') return errors @@ -75,10 +90,20 @@ def release_errors(text): """ errors = [] active = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('#')) + if re.search(r'^\s*(?:-\s*)?<<\s*:', active, re.M): + return ['release ancestry guard merge keys require explicit review'] + if re.search(r'^\s*(?:-\s*)?[\'"][^\'"\n]+[\'"]\s*:', active, re.M): + return ['release ancestry guard quoted mapping keys require explicit review'] + if len(re.findall(r'^jobs:', active, re.M)) != 1 or len(re.findall(r'^ release:', active, re.M)) != 1: + return ['release ancestry guard job is missing or ambiguous'] + if re.search(r'^(?:defaults:| defaults:)', active, re.M): + return ['release ancestry guard defaults require explicit review'] job = re.search(r'^ release:\s*\n(?P.*?)(?=^ [\w-]+:|\Z)', active, re.M | re.S) if not job: return ['release job missing or unsupported layout'] body = job.group('body') + if len(re.findall(r'^ steps:', body, re.M)) != 1: + return ['release ancestry guard steps are missing or ambiguous'] if re.search(r'^ continue-on-error:\s*(?!false\s*(?:#.*)?$)\S', body, re.M): errors.append('release ancestry failure ignored at job scope') steps = re.split(r'(?=^ - )', body, flags=re.M)[1:] @@ -86,9 +111,13 @@ def release_errors(text): for index, step in enumerate(steps): if re.search(r'^(?: - | )uses:\s*actions/checkout@', step, re.M): checkouts.append(index) - options = re.search(r'^ with:\s*\n((?: [^\n]*\n?|\n)*)', step, re.M) - if (not re.search(r'^(?: - | )(?:if|continue-on-error):', step, re.M) - and options and re.search(r'^ fetch-depth:\s*[\'"]?0[\'"]?\s*(?:#.*)?$', options.group(1), re.M)): + options = re.findall(r'^ with:\s*\n((?: [^\n]*\n?|\n)*)', step, re.M) + depth = re.findall(r'^ fetch-depth:\s*(.*?)\s*(?:#.*)?$', options[0], re.M) if len(options) == 1 else [] + uses = re.findall(r'^(?: - | )uses:', step, re.M) + if (len(uses) == 1 and len(re.findall(r'^ with:', step, re.M)) == 1 + and not re.search(r'^(?: - | )if:', step, re.M) + and not re.search(r'^(?: - | )continue-on-error:\s*(?!false\s*(?:#.*)?$)\S', step, re.M) + and len(depth) == 1 and depth[0].strip('\'"') == '0'): full.append(index) run = re.search(r'^ run:\s*\|[-+]?\s*\n((?: [^\n]*\n?|\n)*)', step, re.M) if not run: @@ -98,6 +127,27 @@ def release_errors(text): if not guard: continue guards.append(index) + # This is the supported shell setup, not a substring hunt through arbitrary + # programs. Unknown setup/control flow must be reviewed with new fixtures. + prefix = [line.strip() for line in shell[:guard.start()].splitlines() if line.strip()] + expected = ['set -eu', 'DEF="$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch)"', + 'git fetch --no-tags origin "$DEF"'] + if prefix != expected or len(re.findall(r'^(?: - | )run:', step, re.M)) != 1: + errors.append('release ancestry guard has unsupported setup or duplicate run') + commands = [line.strip() for line in guard.group('failure').replace('\\\n', '').splitlines() if line.strip()] + try: + refusal = shlex.split(commands[-1], comments=True) == ['exit', '1'] + for command in commands[:-1]: + tokens = list(shlex.shlex(command, posix=True, punctuation_chars=';&|')) + if not tokens or tokens[0] != 'echo' or any(t and set(t) <= set(';&|') for t in tokens) or '$(' in command or '`' in command: + refusal = False + except (ValueError, IndexError): + refusal = False + if not refusal: + errors.append('release ancestry guard refusal has unsupported control flow') + shell_option = re.findall(r'^(?: - | )shell:\s*(.*?)\s*$', step, re.M) + if shell_option and shell_option != ['bash']: + errors.append('release ancestry guard shell requires explicit review') if not re.search(r'^\s+exit 1\s*(?:#.*)?$', guard.group('failure'), re.M): errors.append('release ancestry guard has no refusing exit') if re.search(r'^(?: - | )if:', step, re.M): diff --git a/test/member_gates_test.py b/test/member_gates_test.py index d682638..fcdc1b8 100644 --- a/test/member_gates_test.py +++ b/test/member_gates_test.py @@ -28,6 +28,10 @@ def self_test(root): ('duplicate formatted ID', 'board', lambda t: t.replace('GATE-02', '[**GATE-01**](#proof)'), 'board duplicate ID'), ('missing ID header', 'board', lambda t: t.replace('| ID |', '| Key |'), 'board row without ID header'), ('no declared rows', 'board', lambda t: '\n'.join(l for l in t.splitlines() if 'GATE-' not in l), 'board has no declared rows'), + ('separator absent', 'board', lambda t: t.replace('|---|---|---|\n',''), 'board header needs'), + ('separator after data', 'board', lambda t: t.replace('|---|---|---|\n','')+'\n|---|---|---|\n', 'board header needs'), + ('Row header duplicate declaration', 'board', lambda t: t+'\n| Row | State |\n|---|---|\n| GATE-01 | open |\n', 'board Row header unsupported'), + ('Row header unique declaration', 'board', lambda t: t+'\n| Row | State |\n|---|---|\n| GATE-03 | open |\n', 'board Row header unsupported'), ('ancestry command removed', 'release', lambda t: t.replace('git merge-base --is-ancestor', 'git merge-base-removed --is-ancestor'), 'release ancestry guard'), ('ancestry command commented', 'release', lambda t: t.replace('if ! git merge-base', '# if ! git merge-base'), 'release ancestry guard'), ('shallow release checkout', 'release', lambda t: t.replace('fetch-depth: 0', 'fetch-depth: 1'), 'release full-history checkout'), @@ -38,7 +42,24 @@ def self_test(root): ('release job failure ignored', 'release', lambda t: t.replace(' release:\n', ' release:\n continue-on-error: true\n', 1), 'release ancestry failure ignored'), ('checkout disabled', 'release', lambda t: t.replace(' - uses: actions/checkout@v4', ' - if: false\n uses: actions/checkout@v4', 1), 'release full-history checkout'), ('checkout depth moved to env', 'release', lambda t: t.replace(' with:\n ref:', ' env:\n ref:', 1), 'release full-history checkout'), + ('duplicate release job', 'release', lambda t: t + '\n release:\n runs-on: ubuntu-latest\n steps:\n - run: echo bypass\n', 'release ancestry guard'), + ('early successful exit', 'release', lambda t: t.replace(' if ! git merge-base', ' exit 0\n if ! git merge-base', 1), 'release ancestry guard'), + ('duplicate checkout depth', 'release', lambda t: t.replace(' fetch-depth: 0', ' fetch-depth: 0\n fetch-depth: 1', 1), 'release full-history checkout'), + ('duplicate ancestry run', 'release', lambda t: t.replace(' - name: Structural validator', ' run: echo bypass\n\n - name: Structural validator', 1), 'release ancestry guard'), + ('early success in refusal branch', 'release', lambda t: t.replace(' exit 1\n fi\n echo "$(git rev-parse', ' exit 0\n exit 1\n fi\n echo "$(git rev-parse', 1), 'release ancestry guard'), + ('shell swallows failure', 'release', lambda t: t.replace(' - name: The tag must be reachable', ' - shell: bash {0} || true\n name: The tag must be reachable', 1), 'release ancestry guard'), + ('inline duplicate release', 'release', lambda t: t+'\n release: {}\n', 'release ancestry guard'), + ('inline duplicate steps', 'release', lambda t: t.replace(' steps:\n',' steps: []\n steps:\n',1), 'release ancestry guard'), + ('inline duplicate checkout options', 'release', lambda t: t.replace(' with:\n',' with: {}\n with:\n',1), 'release full-history checkout'), + ('merged ancestry options', 'release', lambda t: t.replace(' - name: The tag must be reachable', ' - <<: {continue-on-error: true}\n name: The tag must be reachable',1), 'merge keys'), ] + for quote in ('"', "'"): + defects.extend([ + (f'{quote} quoted release override', 'release', lambda t, q=quote: t+f'\n {q}release{q}:\n uses: ./.github/workflows/validate.yml\n', 'quoted mapping keys'), + (f'{quote} quoted run override', 'release', lambda t, q=quote: t.replace(' - name: Structural validator', f' {q}run{q}: echo bypass\n\n - name: Structural validator',1), 'quoted mapping keys'), + (f'{quote} quoted depth override', 'release', lambda t, q=quote: t.replace(' fetch-depth: 0',f' fetch-depth: 0\n {q}fetch-depth{q}: 1',1), 'quoted mapping keys'), + (f'{quote} quoted steps override', 'release', lambda t, q=quote: t+f'\n {q}steps{q}: []\n', 'quoted mapping keys'), + ]) failures = [] count = 0 # One owned temporary parent; each small copy is removed before the next. @@ -70,10 +91,11 @@ def self_test(root): failures.append(f'{label}: expected refusal {expect!r}; status={status}: {out}') finally: shutil.rmtree(dst) - for label, content in [ - ('escaped pipe', BOARD.replace('Board grammar', r'Board \| grammar')), - ('bold and linked IDs', BOARD.replace('GATE-01', '**GATE-01**').replace('GATE-02', '[GATE-02](#proof)')), - ('different table widths', BOARD + '\n## Second\n\n| ID | State |\n|---|---|\n| GATE-03 | open |\n'), + for label, content, release_patch in [ + ('escaped pipe', BOARD.replace('Board grammar', r'Board \| grammar'), None), + ('bold and linked IDs', BOARD.replace('GATE-01', '**GATE-01**').replace('GATE-02', '[GATE-02](#proof)'), None), + ('different table widths', BOARD + '\n## Second\n\n| ID | State |\n|---|---|\n| GATE-03 | open |\n', None), + ('explicit failure reporting', BOARD, lambda t: t.replace(' - uses: actions/checkout@v4', ' - continue-on-error: false\n uses: actions/checkout@v4', 1)), ]: dst = Path(temporary) / 'repo' shutil.copytree(root, dst, ignore=shutil.ignore_patterns('.git', 'node_modules', '__pycache__', '.agent-sync')) @@ -81,6 +103,9 @@ def self_test(root): board = dst / 'docs/evidence/backlog.md' board.parent.mkdir(parents=True, exist_ok=True) board.write_text(content) + if release_patch: + release = dst / '.github/workflows/release.yml' + release.write_text(release_patch(release.read_text())) status, out = run(dst) count += 1 if status: