diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b4ea5b9..cb4f662 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..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 new file mode 100644 index 0000000..6e408ae --- /dev/null +++ b/docs/evidence/member-gates-20261013/README.md @@ -0,0 +1,158 @@ +# 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/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 +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 + +Implementation and local gates are complete. Next: obtain an independent +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). + + +## 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`: `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 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. +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. + + +## 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 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