diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7b476cd..3dd43c2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,8 +1,20 @@ name: validate on: - push: + # 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 * * *' + 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..a5057c5 --- /dev/null +++ b/docs/evidence/backlog.md @@ -0,0 +1,13 @@ +# telegram-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 new file mode 100644 index 0000000..552752d --- /dev/null +++ b/docs/evidence/member-gates-20261013/README.md @@ -0,0 +1,158 @@ +# Member board and release gates: bounded delivery packet + +Objective: enforce the member's own board grammar and release ancestry contract +through its existing offline validator. Authorized by the 2026-10-13 autonomous +family completion wave; no release, installation or hub pin update is in scope. + +Baseline: canonical `ssheleg/telegram-dev` main `3f9d1d1538090b7ac443b1c38faba946c3f4f7b9`. +The hub's [B-108/B-109 source](https://github.com/ssheleg/sshlg-skills/blob/ab377787/docs/evidence/backlog.md) +requires owner gates and plants. This member has no `docs/evidence/backlog.md`; +`test/validate.py` does not check board rows, `fetch-depth` or `merge-base`. +The release workflow already carries the ancestry check; this task protects it. + +## Requirements and sequence + +| Requirement | Implementation/check | +|---|---| +| GATE-01: own board must exist, declare rows, use its own header width and unique IDs | Shared helper called from `test/validate.py`; malformed pipe, duplicate, missing board/header and normal escaped/bold/link controls | +| GATE-02: active release job must retain full-history checkout and ancestry failure branch | Shared helper, actual validator plants removing/commenting ancestry and changing/misplacing depth; no provider calls | +| GATE-03: check the mechanism independently of this document | Real subprocess validator baseline and mutation runs; every plant proves bytes changed and the expected failure is new | +| GATE-04: maintainable sibling propagation | Prove Telegram first, copy identical helper and regression script to XR/Web3D, adapt only validator integration; parent indexes exact commits | + +Use task-pipeline for delivery, make-skill for the plugin's offline gate contract, +agent-sync for local advisory ownership and evidence-docs for receipts. Existing +user authorization resolves scope and autonomy; no new operator choice is needed. +No skill bodies, manifests or versions change. The release +check is a bounded structural contract for the current workflow, not a general +YAML/Bash interpreter or proof of every possible release bypass. + +## Source ledger and resume + +Read `CLAUDE.md`, `.claude/agent-sync.json`, `docs/AGENT_SYNC.md`, `package.json`, +`test/validate.py`, `.github/workflows/release.yml`, and the hub's +`check_board_is_parseable`/`_one_board` implementation. Canonical remote head was +verified before branching; local main was left untouched. Agent-sync reports +local advisory arbitration, not enforced or cross-machine protection. Fabric +whoami identifies the hub project, but no com.status/list/submit tools are exposed; +the cross-project board cannot be used through this connector. + +Next: independent exact-SHA review of the pushed implementation, then one +draft-to-ready candidate transition. The common helper has been propagated to +the two siblings from their canonical main heads. +Do not call a planted failure or source pass a native/runtime or release +acceptance result. Release/installation and hub pin updates remain out of scope. + +## Observed verification + +- Initial real-validator plants: 12 hostile defects survived the unchanged + validator, exit 1 for the test harness; three valid controls passed. +- Expanded replay with `git show 3f9d1d1:test/validate.py` in an owned temporary + checkout: 18 process controls, 15 failures (every hostile defect survived). +- Initial corrected `python3 test/member_gates_test.py`: 19 process controls, 0 failures, + exit 0. Sixteen hostile plants and three valid controls exercise the real + validator, not a stand-in parser. The final additional plant came from a + self-review counterexample: moving `fetch-depth` from checkout `with` to `env` + was accepted; the corrected gate scopes the value to checkout options. +- `npm test`: exit 0, including structural validator, Telegram fixtures, + installer (11 created/removed scratch homes) and audit regressions. +- `npm run test:negatives`: exit 0, original 15 plants plus the then-current + 18 member controls. After the isolated checkout-options correction, the + complete member-controls script was rerun with all 19 passing; unchanged + Telegram fixture suites were not duplicated. +- `claude plugin validate . --strict` and + `claude plugin validate plugins/telegram-dev --strict`: both exit 0. +- `git diff --check` and Python compilation: exit 0. + +The member validator discloses routed-trigger checking as unlooked because this +isolated worktree has no umbrella ancestor. Hosted CI, release, real Telegram +credentials/provider execution and installed/native model behavior were NOT_RUN. +The coordinator subsequently authorized alignment of validation triggers with +the standing operator policy: schedule at 23:00 Europe/Warsaw, workflow_call for +release, and workflow_dispatch requiring a recorded candidate_reason. Automatic push and PR opened/synchronize triggers are removed; the explicit +ready_for_review candidate transition remains. The final candidate commit does not use a CI skip marker. No full hosted suite was manually dispatched at this +local verification point; a single explicit frozen-candidate run is the allowed +exception, not a new per-push policy. + +The caller scan found only `release.yml` invoking `validate.yml`; there is no +Pages/deployment workflow indirectly running it on a branch push. The release +tag trigger and its dependency on the full validator remain unchanged. The +workflow was parsed with PyYAML locally (exit 0), asserting the exact four +events, ready-only PR event, timezone and required reason; PyYAML is not added to the offline gate. +GitHub documents [IANA timezone schedules](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) +and [dispatch refs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow), +read 2026-10-13. Scheduled runs check the default branch, so they do not certify +this unmerged candidate. The coordinator must match the eligible candidate run to the frozen head or +its test merge commit, never manufacture a required status. + + +## 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 15 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 new file mode 100644 index 0000000..01796c4 --- /dev/null +++ b/test/member_gates.py @@ -0,0 +1,177 @@ +"""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 +import shlex + + +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 + 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 + continue + if fenced: + continue + if not line.startswith('|'): + width = None + continue + first = label(row[0]) if row else '' + 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 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: + 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 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 + + +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('#')) + 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:] + 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.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: + 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) + # 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): + 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..fcdc1b8 --- /dev/null +++ b/test/member_gates_test.py @@ -0,0 +1,122 @@ +#!/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'), + ('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'), + ('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'), + ('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. + 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, 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')) + try: + 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: + 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 29af022..6e5272f 100644 --- a/test/validate.py +++ b/test/validate.py @@ -563,9 +563,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() for n in _notes: