From 361085833e9bb642fa652a1cf1063cd5eb0b9447 Mon Sep 17 00:00:00 2001 From: Micah Villmow <4211002+mvillmow@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:20:18 -0700 Subject: [PATCH] ci: restore required checks on merge groups Signed-off-by: Micah Villmow <4211002+mvillmow@users.noreply.github.com> --- .github/workflows/README.md | 32 +- .github/workflows/_required.yml | 5 +- .github/workflows/extras.yml | 20 +- .github/workflows/merge-queue-smoke.yml | 82 --- docs/CICD_QUALITY_GATES.md | 59 +-- justfile | 8 + pyproject.toml | 2 + scripts/check-merge-queue-readiness.sh | 484 ++++++++++++------ scripts/check-workflow-schema.sh | 30 ++ scripts/run_ci_local.sh | 7 +- scripts/test-workflow-schema-validation.sh | 29 ++ .../merge-queue-smoke.yaml | 12 + .../step-event-guard.yml | 16 + .../workflow-schema/invalid-workflow.yaml | 11 + uv.lock | 143 ++++++ 15 files changed, 641 insertions(+), 299 deletions(-) delete mode 100644 .github/workflows/merge-queue-smoke.yml create mode 100755 scripts/check-workflow-schema.sh create mode 100755 scripts/test-workflow-schema-validation.sh create mode 100644 tests/fixtures/merge-queue-readiness/merge-queue-smoke.yaml create mode 100644 tests/fixtures/merge-queue-readiness/step-event-guard.yml create mode 100644 tests/fixtures/workflow-schema/invalid-workflow.yaml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 2b23c2ea..f93b52c0 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -12,13 +12,13 @@ security scanning of the Keystone HMAS (Hierarchical Multi-Agent System) codebas - **`extras.yml`** emits the separately required `coverage` context and advisory benchmark/NATS checks. - **`release-please.yml`** manages releases after pushes to `main`. -- **`merge-queue-smoke.yml`** is the ONLY workflow that runs for - `merge_group/checks_requested`. It runs a single fast `merge-queue-smoke` - job (< 5 minutes, one runner slot) of real validation steps. Full CI - validates every commit at pull-request time and is left untouched. -Existing push, pull-request, manual, and scheduled behavior is unchanged; only -the merge-queue surface moved to the dedicated smoke workflow. +The live baseline also requires `_required.yml`'s `install` job; the complete +context list is recorded in the quality-gates guide below. + +The required and extras workflows execute their real jobs for both pull-request +and `merge_group/checks_requested` commits. Event-and-SHA-scoped concurrency +keeps those independent validation runs from cancelling one another. ## Workflow Details @@ -205,15 +205,19 @@ All workflows run on: ### Merge Queue -Only `merge-queue-smoke.yml` runs for `merge_group/checks_requested`. GitHub -creates that event against the speculative group ref; the `merge-queue-smoke` -job re-validates fast repository invariants there, while the exhaustive suites -run at pull-request time (each queued commit already passed them on its PR). - -Queue activation is a separate post-merge administration step (every ruleset's -required contexts must first be replaced with `merge-queue-smoke`). See +`_required.yml` and `extras.yml` both run for `merge_group/checks_requested`. +GitHub creates that event against the speculative group ref, and every live +required context is re-emitted by the same real producers that validate pull +requests. + +Queue activation and any required-context cutover remain separate post-merge +administration steps. The active queue currently uses `HEADGREEN`; this +producer change does not mutate it. +[Keystone #671](https://github.com/HomericIntelligence/Keystone/issues/671) +owns the live emit-before-require cutover, full ruleset read-back, and rollback +evidence. See [CI/CD Quality Gates](../../docs/CICD_QUALITY_GATES.md#merge-queue-activation) -for the preserved required-context baseline and approved queue policy. +for the observed live state and ownership boundary. ### Push Events diff --git a/.github/workflows/_required.yml b/.github/workflows/_required.yml index c8366c1a..dc4b6029 100644 --- a/.github/workflows/_required.yml +++ b/.github/workflows/_required.yml @@ -12,10 +12,13 @@ on: pull_request: branches: [main, develop] + merge_group: + types: [checks_requested] + workflow_dispatch: concurrency: - group: required-${{ github.ref }} + group: required-${{ github.event_name }}-${{ github.sha }} cancel-in-progress: true permissions: diff --git a/.github/workflows/extras.yml b/.github/workflows/extras.yml index 8c33fa84..eb08e068 100644 --- a/.github/workflows/extras.yml +++ b/.github/workflows/extras.yml @@ -1,25 +1,20 @@ name: Extras (non-blocking) -# Benchmarks, NATS integration, and coverage — informational only, not required for merge. -# These jobs produce build artifacts and Codecov reports but do not block PRs. +# Benchmarks and NATS integration are informational. Coverage emits a live +# required context, so the complete workflow runs for pull requests and +# synthetic merge-group commits. on: push: branches: [main, develop, "claude/**"] pull_request: branches: [main, develop] - # The `coverage` context is required by the live main ruleset, so this - # workflow must keep triggering on merge_group to emit it until the - # ruleset is flipped to require only merge-queue-smoke (tracked - # separately; needs org-admin credentials to execute). Every job below - # skips merge-queue builds (`if: github.event_name != 'merge_group'`): the - # skipped conclusion satisfies the required context, while under ALLGREEN - # grouping an actually-run sub-threshold `coverage` reading (it exits 1 - # below its 75% target) would eject otherwise-green PRs from the queue. + merge_group: + types: [checks_requested] workflow_dispatch: concurrency: - group: extras-${{ github.ref }} + group: extras-${{ github.event_name }}-${{ github.sha }} cancel-in-progress: true permissions: @@ -28,7 +23,6 @@ permissions: jobs: benchmarks: name: benchmarks - if: github.event_name != 'merge_group' runs-on: ubuntu-24.04 # Raised from 20 to 30: the install-build-deps step (apt-get update + conan # install) intermittently exceeds 20 minutes on GitHub-hosted runners during @@ -98,7 +92,6 @@ jobs: nats-integration: name: NATS integration tests - if: github.event_name != 'merge_group' runs-on: ubuntu-24.04 # 20 (was 15): leave headroom for a cold dev-image rebuild — any # Containerfile/conanfile.py change misses the exact-match image cache in @@ -179,7 +172,6 @@ jobs: coverage: name: coverage - if: github.event_name != 'merge_group' runs-on: ubuntu-24.04 timeout-minutes: 25 diff --git a/.github/workflows/merge-queue-smoke.yml b/.github/workflows/merge-queue-smoke.yml deleted file mode 100644 index 7ae2606e..00000000 --- a/.github/workflows/merge-queue-smoke.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Merge Queue Smoke - -# Sole merge_group workflow. Full CI (up to 16 jobs re-queued per merge group, -# 70-90 min under runner-slot starvation) already ran on the pull_request; the -# queue only needs a fast real sanity gate. One job = one runner slot, < 5 min. -# PR-side CI is untouched: this workflow emits the single `merge-queue-smoke` -# context that the branch rulesets gate the queue on after rollout. - -on: - merge_group: - types: [checks_requested] - -permissions: - contents: read - -concurrency: - group: mq-smoke-${{ github.ref }} - cancel-in-progress: true - -jobs: - merge-queue-smoke: - name: merge-queue-smoke - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Run symlink check - run: bash scripts/check-symlinks.sh - - - name: Validate merge-queue readiness contract - run: ./scripts/check-merge-queue-readiness.sh - - - name: Validate GitHub Actions workflow YAML files - run: | - pip install --quiet check-jsonschema - find .github/workflows -name "*.yml" | sort | \ - xargs check-jsonschema \ - --schemafile https://json.schemastore.org/github-workflow - - - name: Verify version consistency across release surfaces - run: | - python3 - <<'PYEOF' - import json, re, pathlib, sys, tomllib - - root = pathlib.Path(".") - - manifest_ver = json.loads( - (root / ".release-please-manifest.json").read_text() - ).get(".") - - cmake_text = (root / "CMakeLists.txt").read_text() - cmake_m = re.search(r"project\s*\([^)]*VERSION\s+([\d.]+)", cmake_text, re.DOTALL) - cmake_ver = cmake_m.group(1) if cmake_m else None - - conan_m = re.search( - r'version\s*=\s*["\']([^"\']+)["\']', - (root / "conanfile.py").read_text(), - ) - conan_ver = conan_m.group(1) if conan_m else None - - with open(root / "pixi.toml", "rb") as fh: - pixi_ver = tomllib.load(fh).get("project", {}).get("version") - - versions = { - ".release-please-manifest.json": manifest_ver, - "CMakeLists.txt": cmake_ver, - "conanfile.py": conan_ver, - "pixi.toml": pixi_ver, - } - for k, v in versions.items(): - print(f"{k:32s}: {v}") - - missing = [k for k, v in versions.items() if v is None] - if missing: - print(f"\nVersion not found in: {', '.join(missing)}") - sys.exit(1) - if len(set(versions.values())) != 1: - print("\nRelease surfaces disagree on version") - sys.exit(1) - print(f"\nAll release surfaces agree: {manifest_ver}") - PYEOF diff --git a/docs/CICD_QUALITY_GATES.md b/docs/CICD_QUALITY_GATES.md index f5321876..55b62f38 100644 --- a/docs/CICD_QUALITY_GATES.md +++ b/docs/CICD_QUALITY_GATES.md @@ -298,7 +298,8 @@ The `quality-summary` job runs after all quality gates complete: The live `main` rulesets require these existing GitHub Actions contexts. Merge queue rollout must preserve all of them: -- `lint`, `unit-tests`, `integration-tests`, `test`, `build`, and `package` +- `lint`, `unit-tests`, `integration-tests`, `test`, `build`, `install`, and + `package` - `security/dependency-scan` and `security/secrets-scan` - `schema-validation` and `deps/version-sync` - `coverage` and `release` @@ -310,47 +311,21 @@ rules. ## Merge Queue Activation -The workflows are prepared for a staged merge-queue rollout by handling -`merge_group/checks_requested`. This repository has no canonical, repo-owned -ruleset configuration file, so an administrator must perform activation only -after this readiness change merges and the repository smoke check is ready. - -Add one `merge_queue` rule to the existing active `homeric-main-baseline` -ruleset while preserving every existing condition, bypass actor, rule, required -context, and enforcement field. The rule must use this exact payload: - -```json -{ - "type": "merge_queue", - "parameters": { - "check_response_timeout_minutes": 60, - "grouping_strategy": "ALLGREEN", - "max_entries_to_build": 10, - "max_entries_to_merge": 5, - "merge_method": "SQUASH", - "min_entries_to_merge": 1, - "min_entries_to_merge_wait_minutes": 5 - } -} -``` - -Activation is a full-replacement ruleset write and therefore requires this -operator sequence: - -1. Fetch and retain the complete live ruleset as the rollback snapshot. -2. Verify the snapshot includes all required contexts listed above and every - current non-queue rule. -3. Append only the queue rule, update the ruleset, and read it back. -4. Compare the read-back with the snapshot, allowing only the new queue rule. - If any unrelated field changed, immediately restore the snapshot. -5. Enqueue one representative pull request and record the - `merge_group/checks_requested` workflow run, all required check conclusions, - and the queued squash merge result on - [Keystone #610](https://github.com/HomericIntelligence/Keystone/issues/610). - -This activation belongs to the post-merge rollout tracked by -[Odysseus #386](https://github.com/HomericIntelligence/Odysseus/issues/386); it -must not be performed from the implementation PR. +The active `homeric-main-baseline` ruleset already has a merge queue. Its +observed live queue state is `HEADGREEN` with 10 entries building concurrently, +groups of 1–5 entries, a five-minute group wait, a 60-minute check timeout, and +squash merge. These values describe current state; they are not an executable +desired-state template, and this producer change does not mutate them. + +The required and extras workflows handle `merge_group/checks_requested` so a +representative synthetic commit can emit the same real contexts as its pull +request. Live context replacement and the final queue-policy transition are +owned exclusively by +[Keystone #671](https://github.com/HomericIntelligence/Keystone/issues/671). +That operational issue requires snapshot, emit-before-require probes, complete +GET-derived updates, read-back comparison, and rollback evidence before the +queue moves to its final 180-minute aggregate-gate baseline. No ruleset write +or queue activation belongs in this implementation PR. ## Local Validation diff --git a/justfile b/justfile index 4fa6ef4b..1a5b0927 100644 --- a/justfile +++ b/justfile @@ -52,6 +52,14 @@ check-extraction: check-merge-queue-readiness: ./scripts/check-merge-queue-readiness.sh +# Fail-closed GitHub Actions schema validation for both workflow extensions. +check-workflow-schema: + ./scripts/check-workflow-schema.sh + +# Prove that semantic workflow-schema failures are rejected. +test-workflow-schema-validation: + ./scripts/test-workflow-schema-validation.sh + format: uv run make format diff --git a/pyproject.toml b/pyproject.toml index a01be2f5..36ccf0e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,8 @@ dev = [ "pytest>=8.0", "ruff>=0.9", "pip-audit>=2.7", + # Fail-closed validation for every committed GitHub Actions workflow. + "check-jsonschema>=0.38.0,<1", ] # Tooling-only manifest: there is no importable `keystone` Python package to diff --git a/scripts/check-merge-queue-readiness.sh b/scripts/check-merge-queue-readiness.sh index 838e849a..00544830 100755 --- a/scripts/check-merge-queue-readiness.sh +++ b/scripts/check-merge-queue-readiness.sh @@ -2,149 +2,345 @@ set -euo pipefail -failures=0 - -fail() { - echo "ERROR: $*" >&2 - failures=$((failures + 1)) -} - -require_line() { - local file=$1 - local pattern=$2 - local description=$3 - - if ! grep -Eq "$pattern" "$file"; then - fail "$file: missing $description" - fi -} - -require_merge_group_trigger() { - local file=$1 - local trigger_block - - trigger_block=$(sed -n '/^on:/,/^[^[:space:]]/p' "$file") - if ! grep -Eq '^ merge_group:$' <<<"$trigger_block"; then - fail "$file: missing merge_group trigger" - fi - if ! grep -Eq '^ types: \[checks_requested\]$' <<<"$trigger_block"; then - fail "$file: merge_group must be limited to checks_requested" - fi -} - -forbid_merge_group_trigger() { - local file=$1 - local trigger_block - - trigger_block=$(sed -n '/^on:/,/^[^[:space:]]/p' "$file") - if grep -Eq '^ merge_group:$' <<<"$trigger_block"; then - fail "$file: must not trigger on merge_group — queue builds run only merge-queue-smoke.yml (single fast runner slot)" - fi -} - -# merge_group is carried EXCLUSIVELY by the dedicated smoke workflow so a -# merge group consumes one runner slot for < 5 minutes instead of re-running -# the full 16-job suite (70-90 min under runner-slot starvation). -require_merge_group_trigger .github/workflows/merge-queue-smoke.yml - -for workflow in \ - .github/workflows/_required.yml \ - .github/workflows/extras.yml \ - .github/workflows/release-please.yml; do - forbid_merge_group_trigger "$workflow" -done - -# The smoke workflow must emit exactly the merge-queue-smoke context the -# queue gates on; PR-side workflows are left untouched. -require_line .github/workflows/merge-queue-smoke.yml \ - '^ name: merge-queue-smoke$' \ - 'merge-queue-smoke context in the smoke workflow' - -required_contexts=( - lint - unit-tests - integration-tests - security/dependency-scan - security/secrets-scan - build - schema-validation - deps/version-sync - test - package - release - coverage +# PyYAML is already present in Keystone's locked development environment via +# Conan and pre-commit. Use the lock so this check behaves the same locally +# and in the CI image. +exec uv run --frozen python - <<'PYEOF' +from __future__ import annotations + +import copy +import re +import sys +from pathlib import Path +from typing import Any + +import yaml + + +class WorkflowLoader(yaml.SafeLoader): + """YAML 1.2-style booleans for GitHub workflow keys such as `on`.""" + + +WorkflowLoader.yaml_implicit_resolvers = copy.deepcopy( + yaml.SafeLoader.yaml_implicit_resolvers +) +for first_character, resolvers in WorkflowLoader.yaml_implicit_resolvers.items(): + WorkflowLoader.yaml_implicit_resolvers[first_character] = [ + resolver for resolver in resolvers if resolver[0] != "tag:yaml.org,2002:bool" + ] +WorkflowLoader.add_implicit_resolver( + "tag:yaml.org,2002:bool", + re.compile(r"^(?:true|false)$", re.IGNORECASE), + list("tTfF"), +) + + +PRODUCERS = ( + Path(".github/workflows/_required.yml"), + Path(".github/workflows/extras.yml"), +) +PUBLISHER = Path(".github/workflows/release-please.yml") +WORKFLOW_PATTERNS = ("*.yml", "*.yaml") +SMOKE_FILENAMES = frozenset({"merge-queue-smoke.yml", "merge-queue-smoke.yaml"}) + +# Each exception must bind the workflow path, job id, step name, and exact +# condition. There are currently no intentional event guards in a required +# validator; publication-only exceptions can be added here when evidence calls +# for one without weakening the scan or validation step that precedes it. +ALLOWED_REQUIRED_STEP_EVENT_GUARDS: frozenset[tuple[str, str, str, str]] = frozenset() + +# Define the live required-context contract once. Producer ownership is +# derived from each workflow's job names below, including the duplicate +# `coverage` producer that currently exists in both real workflows. +REQUIRED_CONTEXTS = frozenset( + { + "build", + "coverage", + "deps/version-sync", + "install", + "integration-tests", + "lint", + "package", + "release", + "schema-validation", + "security/dependency-scan", + "security/secrets-scan", + "test", + "unit-tests", + } +) + +failures: list[str] = [] +observed_step_guard_allowlist: set[tuple[str, str, str, str]] = set() + + +def fail(message: str) -> None: + failures.append(message) + print(f"ERROR: {message}", file=sys.stderr) + + +def load_workflow(path: Path) -> dict[str, Any]: + try: + document = yaml.load(path.read_text(encoding="utf-8"), Loader=WorkflowLoader) + except (OSError, UnicodeError, yaml.YAMLError) as error: + fail(f"{path}: cannot parse workflow YAML: {error}") + return {} + + if not isinstance(document, dict): + fail(f"{path}: workflow root must be a mapping") + return {} + if "on" not in document: + fail(f"{path}: parser did not preserve the literal `on` key") + return document + + +def discover_workflow_files(directory: Path) -> list[Path]: + paths = { + path + for pattern in WORKFLOW_PATTERNS + for path in directory.glob(pattern) + if path.is_file() + } + return sorted(paths) + + +def smoke_carrier_violations(paths: list[Path]) -> list[str]: + violations: list[str] = [] + for path in paths: + if path.name in SMOKE_FILENAMES: + violations.append(f"{path}: smoke-only merge-group carrier remains") + + workflow = load_workflow(path) + jobs = workflow.get("jobs") + if not isinstance(jobs, dict): + continue + for job_id, job in jobs.items(): + name = job.get("name") if isinstance(job, dict) else None + if job_id == "merge-queue-smoke" or name == "merge-queue-smoke": + violations.append(f"{path}: smoke-only merge-queue job remains") + return violations + + +def require_event_parity(path: Path, workflow: dict[str, Any]) -> None: + events = workflow.get("on") + if not isinstance(events, dict): + fail(f"{path}: `on` must be an event mapping") + return + + push = events.get("push") + if not isinstance(push, dict) or push.get("branches") != [ + "main", + "develop", + "claude/**", + ]: + fail(f"{path}: existing push branches changed or are missing") + + pull_request = events.get("pull_request") + if not isinstance(pull_request, dict) or pull_request.get("branches") != [ + "main", + "develop", + ]: + fail(f"{path}: existing pull_request branches changed or are missing") + + merge_group = events.get("merge_group") + if not isinstance(merge_group, dict): + fail(f"{path}: missing merge_group event mapping") + elif merge_group.get("types") != ["checks_requested"]: + fail(f"{path}: merge_group must be limited to checks_requested") + + +def require_event_sha_concurrency(path: Path, workflow: dict[str, Any]) -> None: + concurrency = workflow.get("concurrency") + if not isinstance(concurrency, dict): + fail(f"{path}: concurrency must be a mapping") + return + + group = concurrency.get("group") + if not isinstance(group, str): + fail(f"{path}: concurrency.group must be a string") + return + for expression in ("${{ github.event_name }}", "${{ github.sha }}"): + if expression not in group: + fail(f"{path}: concurrency.group must include {expression}") + + if concurrency.get("cancel-in-progress") is not True: + fail(f"{path}: cancel-in-progress must remain true") + + +def job_names(path: Path, workflow: dict[str, Any]) -> dict[str, str]: + jobs = workflow.get("jobs") + if not isinstance(jobs, dict): + fail(f"{path}: jobs must be a mapping") + return {} + + names: dict[str, str] = {} + for job_id, job in jobs.items(): + if not isinstance(job_id, str) or not isinstance(job, dict): + fail(f"{path}: malformed job entry {job_id!r}") + continue + name = job.get("name") + if not isinstance(name, str): + fail(f"{path}: job {job_id} must have a stable name") + continue + names[job_id] = name + + condition = job.get("if") + if isinstance(condition, str) and ( + "github.event" in condition + or "merge_group" in condition + or "pull_request" in condition + ): + fail( + f"{path}: job {job_id} has an event condition that breaks " + "pull-request/merge-group parity" + ) + return names + + +def required_step_event_guard_violations( + path: Path, + workflow: dict[str, Any], + names: dict[str, str], +) -> list[str]: + jobs = workflow.get("jobs", {}) + violations: list[str] = [] + for job_id, name in names.items(): + if name not in REQUIRED_CONTEXTS: + continue + job = jobs[job_id] + steps = job.get("steps") + if not isinstance(steps, list): + continue + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + condition = step.get("if") + if not isinstance(condition, str) or not ( + "github.event" in condition + or "merge_group" in condition + or "pull_request" in condition + ): + continue + step_name = step.get("name") + if not isinstance(step_name, str): + step_name = f"step[{index}]" + key = (path.as_posix(), job_id, step_name, condition.strip()) + if key in ALLOWED_REQUIRED_STEP_EVENT_GUARDS: + observed_step_guard_allowlist.add(key) + continue + violations.append( + f"{path}: required context {name} ({job_id}) step " + f"{step_name!r} has an unapproved event guard: {condition}" + ) + return violations + + +def require_context_reachability( + path: Path, + workflow: dict[str, Any], + names: dict[str, str], +) -> set[str]: + owned_contexts = REQUIRED_CONTEXTS.intersection(names.values()) + if not owned_contexts: + fail(f"{path}: producer emits no live required context") + return set() + + jobs = workflow.get("jobs", {}) + for context in sorted(owned_contexts): + matching_jobs = [job_id for job_id, name in names.items() if name == context] + for job_id in matching_jobs: + job = jobs[job_id] + if "if" in job: + fail( + f"{path}: required context {context} ({job_id}) must be " + "unconditional for pull_request and merge_group" + ) + for violation in required_step_event_guard_violations(path, workflow, names): + fail(violation) + return set(owned_contexts) + + +for violation in smoke_carrier_violations( + discover_workflow_files(Path(".github/workflows")) +): + fail(violation) + +emitted_required_contexts: set[str] = set() +for producer in PRODUCERS: + workflow = load_workflow(producer) + require_event_parity(producer, workflow) + require_event_sha_concurrency(producer, workflow) + names = job_names(producer, workflow) + emitted_required_contexts.update( + require_context_reachability(producer, workflow, names) + ) + +missing_contexts = REQUIRED_CONTEXTS.difference(emitted_required_contexts) +if missing_contexts: + fail( + "live required contexts missing from real producers: " + + ", ".join(sorted(missing_contexts)) + ) + +unused_step_guard_allowlist = ( + ALLOWED_REQUIRED_STEP_EVENT_GUARDS - observed_step_guard_allowlist +) +if unused_step_guard_allowlist: + fail( + "unused required-step event-guard allowlist entries: " + + repr(sorted(unused_step_guard_allowlist)) + ) + +schema_runner = Path("scripts/run_ci_local.sh").read_text(encoding="utf-8") +function_start = schema_runner.find("run_schema-validation() {") +function_end = schema_runner.find("\n}", function_start) +if function_start < 0 or function_end < 0: + fail("scripts/run_ci_local.sh: missing run_schema-validation function") +else: + schema_body = schema_runner[function_start:function_end] + for command in ( + "./scripts/check-workflow-schema.sh", + "./scripts/test-workflow-schema-validation.sh", + "./scripts/check-merge-queue-readiness.sh", + ): + if command not in schema_body: + fail(f"scripts/run_ci_local.sh: schema-validation must execute {command}") + if "|| true" in schema_body: + fail("scripts/run_ci_local.sh: schema-validation must fail closed") + +step_guard_fixture_path = Path( + "tests/fixtures/merge-queue-readiness/step-event-guard.yml" ) +step_guard_fixture = load_workflow(step_guard_fixture_path) +fixture_names = job_names(step_guard_fixture_path, step_guard_fixture) +fixture_guard_violations = required_step_event_guard_violations( + step_guard_fixture_path, + step_guard_fixture, + fixture_names, +) +if not fixture_guard_violations: + fail("step-level event-guard negative fixture was not rejected") + +alternate_fixture_dir = Path("tests/fixtures/merge-queue-readiness") +alternate_fixture_violations = smoke_carrier_violations( + discover_workflow_files(alternate_fixture_dir) +) +if not any( + "merge-queue-smoke.yaml" in violation for violation in alternate_fixture_violations +): + fail("alternate-extension smoke-carrier negative fixture was not discovered") + +publisher = load_workflow(PUBLISHER) +publisher_events = publisher.get("on") +if isinstance(publisher_events, dict) and "merge_group" in publisher_events: + fail(f"{PUBLISHER}: artifact publisher must not run on merge_group") + +if failures: + print( + f"Merge-queue readiness validation failed with {len(failures)} error(s).", + file=sys.stderr, + ) + raise SystemExit(1) -for context in "${required_contexts[@]}"; do - if ! grep -Ehq "^[[:space:]]+name: ${context}$" \ - .github/workflows/_required.yml \ - .github/workflows/extras.yml \ - .github/workflows/release-please.yml; then - fail "required context is not emitted by a queue-enabled workflow: $context" - fi -done - -# Preserve the existing push and pull-request surfaces while adding queue builds. -for workflow in .github/workflows/_required.yml .github/workflows/extras.yml; do - require_line "$workflow" '^ push:$' 'push trigger' - require_line "$workflow" '^ pull_request:$' 'pull_request trigger' - require_line "$workflow" '^ branches: \[main, develop, "claude/\*\*"\]$' \ - 'existing push branches' - require_line "$workflow" '^ branches: \[main, develop\]$' \ - 'existing pull-request branches' -done - -# Merge-group refs must never execute release-please or publish release artifacts. -require_line .github/workflows/release-please.yml \ - "^ if: github\.event_name != 'merge_group'$" \ - 'merge-group guard on release-please' -# The canonical `release` required-check is emitted by the PR-time validation -# job in _required.yml (which triggers on push/pull_request/merge_group), NOT by -# release-please.yml — a second release-please.yml job named `release` would -# collide with it on the `release` check name. The generic required_contexts -# loop above already confirms the `release` context is emitted by a -# queue-enabled workflow; assert here that its sole producer is _required.yml. -require_line .github/workflows/_required.yml \ - '^ name: release$' \ - 'canonical release required-check gate in _required.yml' -require_line .github/workflows/_required.yml \ - '^ run: \./scripts/check-merge-queue-readiness\.sh$' \ - 'required schema-validation invocation of this contract' - -# extras.yml supplies the required coverage context. Its advisory heavy jobs stay -# available on push/PR, but do not consume merge-queue build capacity. The -# coverage job must also skip merge_group: its skipped conclusion still -# satisfies the required context, whereas a run that exits 1 below the -# advisory threshold would eject otherwise-green PRs under ALLGREEN grouping. -for job in benchmarks nats-integration coverage; do - if ! awk -v job="$job" ' - $0 == " " job ":" { in_job=1; next } - in_job && /^ [[:alnum:]_-]+:/ { exit } - in_job && /if: github\.event_name != '\''merge_group'\''/ { found=1 } - END { exit(found ? 0 : 1) } - ' .github/workflows/extras.yml; then - fail ".github/workflows/extras.yml: $job must skip merge_group builds" - fi -done - -# The repository has no canonical ruleset file, so the activation contract must -# remain exact and reviewable in version-controlled documentation. -for policy in \ - '"check_response_timeout_minutes": 60' \ - '"grouping_strategy": "ALLGREEN"' \ - '"max_entries_to_build": 10' \ - '"max_entries_to_merge": 5' \ - '"merge_method": "SQUASH"' \ - '"min_entries_to_merge": 1' \ - '"min_entries_to_merge_wait_minutes": 5'; do - if ! grep -Fq "$policy" docs/CICD_QUALITY_GATES.md; then - fail "docs/CICD_QUALITY_GATES.md: missing queue policy $policy" - fi -done - -if ((failures > 0)); then - echo "Merge-queue readiness validation failed with $failures error(s)." >&2 - exit 1 -fi - -echo "Merge-queue readiness validation passed." +print("Merge-queue readiness validation passed.") +PYEOF diff --git a/scripts/check-workflow-schema.sh b/scripts/check-workflow-schema.sh new file mode 100755 index 00000000..2b441e1c --- /dev/null +++ b/scripts/check-workflow-schema.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly schema_name="vendor.github-workflows" + +declare -a roots=() +if (($# > 0)); then + roots=("$@") +else + roots=(.github/workflows) +fi +declare -a workflow_files=() + +while IFS= read -r -d '' workflow; do + workflow_files+=("$workflow") +done < <( + find "${roots[@]}" -type f \ + \( -name '*.yml' -o -name '*.yaml' \) \ + -print0 | sort -z +) + +if ((${#workflow_files[@]} == 0)); then + echo "ERROR: no .yml or .yaml workflow files found" >&2 + exit 1 +fi + +uv run --frozen check-jsonschema \ + --builtin-schema "$schema_name" \ + "${workflow_files[@]}" diff --git a/scripts/run_ci_local.sh b/scripts/run_ci_local.sh index 3a825aa1..fbd315b5 100755 --- a/scripts/run_ci_local.sh +++ b/scripts/run_ci_local.sh @@ -157,8 +157,11 @@ run_integration-tests() { } run_schema-validation() { - # Schema validation - run_in_container "uv run check-jsonschema --check-metaschema .github/workflows/*.yml 2>/dev/null || true" + # GitHub workflow schema validation and its negative regression are both + # fail-closed. Merge-queue topology is an additional structural contract. + run_in_container "./scripts/check-workflow-schema.sh" + run_in_container "./scripts/test-workflow-schema-validation.sh" + run_in_container "./scripts/check-merge-queue-readiness.sh" } run_security-secrets-scan() { diff --git a/scripts/test-workflow-schema-validation.sh b/scripts/test-workflow-schema-validation.sh new file mode 100755 index 00000000..c6959e49 --- /dev/null +++ b/scripts/test-workflow-schema-validation.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fixture_dir=tests/fixtures/workflow-schema + +set +e +output=$(./scripts/check-workflow-schema.sh "$fixture_dir" 2>&1) +status=$? +set -e + +if ((status == 0)); then + echo "ERROR: invalid .yaml workflow fixture passed schema validation" >&2 + exit 1 +fi + +if ((status != 1)); then + echo "ERROR: schema validator exited $status instead of rejecting the fixture" >&2 + printf '%s\n' "$output" >&2 + exit 1 +fi + +if [[ "$output" != *"Schema validation errors were encountered"* ]]; then + echo "ERROR: invalid fixture did not produce a schema-validation error" >&2 + printf '%s\n' "$output" >&2 + exit 1 +fi + +echo "Invalid .yaml workflow fixture was rejected as expected." diff --git a/tests/fixtures/merge-queue-readiness/merge-queue-smoke.yaml b/tests/fixtures/merge-queue-readiness/merge-queue-smoke.yaml new file mode 100644 index 00000000..0bcded9b --- /dev/null +++ b/tests/fixtures/merge-queue-readiness/merge-queue-smoke.yaml @@ -0,0 +1,12 @@ +name: Alternate-extension smoke fixture + +on: + merge_group: + types: [checks_requested] + +jobs: + merge-queue-smoke: + name: merge-queue-smoke + runs-on: ubuntu-latest + steps: + - run: "true" diff --git a/tests/fixtures/merge-queue-readiness/step-event-guard.yml b/tests/fixtures/merge-queue-readiness/step-event-guard.yml new file mode 100644 index 00000000..95fc88e9 --- /dev/null +++ b/tests/fixtures/merge-queue-readiness/step-event-guard.yml @@ -0,0 +1,16 @@ +name: Required validator step guard fixture + +on: + pull_request: + branches: [main, develop] + merge_group: + types: [checks_requested] + +jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - name: Suppressed required validation + if: github.event_name != 'merge_group' + run: "true" diff --git a/tests/fixtures/workflow-schema/invalid-workflow.yaml b/tests/fixtures/workflow-schema/invalid-workflow.yaml new file mode 100644 index 00000000..ff5da284 --- /dev/null +++ b/tests/fixtures/workflow-schema/invalid-workflow.yaml @@ -0,0 +1,11 @@ +name: Invalid workflow schema fixture + +on: + push: + +jobs: + invalid: + name: invalid + runs-on: ubuntu-latest + steps: + - name: Step with no run or uses directive diff --git a/uv.lock b/uv.lock index 97d302ac..f74207b4 100644 --- a/uv.lock +++ b/uv.lock @@ -46,6 +46,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "boolean-py" version = "5.0" @@ -113,6 +122,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] +[[package]] +name = "check-jsonschema" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jsonschema" }, + { name = "regress" }, + { name = "requests" }, + { name = "ruamel-yaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/a0/d5ab25da3d4612a47b1e68cbbdffdc076ca3a9771027573d4ed9f4304d49/check_jsonschema-0.38.0.tar.gz", hash = "sha256:bb6899cc24ab0ced664e4646423d35b72db245cea75109c0b9e64d13087d1b8c", size = 436740, upload-time = "2026-08-09T04:31:07.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/8b/810a6d7048cfc3db2804b36ed6ff8e8cd0855a88b9a205752f27a4521f85/check_jsonschema-0.38.0-py3-none-any.whl", hash = "sha256:a4fa877ae92b1df812c601b68fec75fe10d1f7d827a7e7f6b218f6712deead2d", size = 420163, upload-time = "2026-08-09T04:31:05.412Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "cmake" version = "4.4.2" @@ -294,6 +328,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "keystone-buildtools" version = "0.1.0" @@ -301,6 +362,7 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "check-jsonschema" }, { name = "cmake" }, { name = "conan" }, { name = "gcovr" }, @@ -316,6 +378,7 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "check-jsonschema", specifier = ">=0.38.0,<1" }, { name = "cmake", specifier = ">=4.4.2" }, { name = "conan", specifier = ">=2.31.2" }, { name = "gcovr", specifier = ">=7.0" }, @@ -738,6 +801,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regress" +version = "2025.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/bf/faa406189856f9b566fa1c42d173188d3e86cd5116484a663922365e0004/regress-2025.10.1.tar.gz", hash = "sha256:dcc0a8af0cdbc3d6e0d4725f113335d0a5ffbba86ae3ca18d2b5b352c5f2c8ed", size = 11567, upload-time = "2025-10-09T07:09:48.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/bd/f7ff13577c688efec36488554489635c135ab7c2b3652c00aaa3d74bd761/regress-2025.10.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9100da34f69e18ad6d545f5d74f8ee729e42ce200a73752dd6d94f8a373d0e71", size = 445469, upload-time = "2025-10-09T07:08:16.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/3d/e8fc72e7424c168dfc4cdf3a864f189f6d3afbb6e3ed66455774d1f5e2ba/regress-2025.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e25096697b034848d8fc7910cdb38b7abc2bd2d7ade8767893359918ed4efd0", size = 434712, upload-time = "2025-10-09T07:08:17.817Z" }, + { url = "https://files.pythonhosted.org/packages/63/d0/a18cc39008765d2b964bc2eba423416545d97b4f2623b99549c5e97e4d37/regress-2025.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4c40a8c9f3e0119d2384a52b55dcc770461e1ead6ae7b41314999223116a15", size = 514752, upload-time = "2025-10-09T07:08:19.148Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/57e64ba417867b39ad9ca55feaea80d3176735dba36e6a92ff21b8224fcb/regress-2025.10.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25d8518285aa3ce67ddbede90a1a9ca6a5d23a1b8275dd5a9722af0c64b37b2a", size = 497412, upload-time = "2025-10-09T07:08:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/72/6c/2fd3d3877c1957408548f7a380580f0b64ef8aff69f7fc2cc182a79b0b0b/regress-2025.10.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8051fac3696730bb84d7675c62c7073792a0e105233d4f8e1055f2cab9b04fce", size = 676998, upload-time = "2025-10-09T07:08:21.881Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/acde146473453f3acb5dd29e5be0fb627d87ad020ec5674ee133cde24261/regress-2025.10.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e337ce3b150ca0ff599b1150b995ff6a2e32b5940e17ac3f30a29133960709e", size = 576129, upload-time = "2025-10-09T07:08:23.354Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b0/8007436b33496ec8f10d7b7a67a7a669569c82fc2a182d4f928ab4fd11da/regress-2025.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290a15652c3fafe387db02061d4ed9a100804ed5a162d069b5a0ac28b5df162a", size = 507510, upload-time = "2025-10-09T07:08:24.34Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/6ac6cd1de7e34e34cb492df627847de0d01cee982c7dd7d100e67cf1aa93/regress-2025.10.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b4bc2008b59e5124c1672d7fd9f203e5d4a4ff88ebaf4666e3281141e2d8db20", size = 520595, upload-time = "2025-10-09T07:08:25.35Z" }, + { url = "https://files.pythonhosted.org/packages/11/26/35556d1dd6f64122a16166db7651d1a5804edebea9835cbcd452d9b7c2ff/regress-2025.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:56ef2f8ef9a102a7d42cbbc2f3c0c5e1b186bd8eaa78d564da566b0bf20653dc", size = 695584, upload-time = "2025-10-09T07:08:26.52Z" }, + { url = "https://files.pythonhosted.org/packages/e9/33/07650dfc042b55270fc269276112c592d458cdfb8d31c85447ff743dcf10/regress-2025.10.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0680ac0b0a0058acb55b64aa56956732cf56b0baa84ea95e3fa124ab16da58aa", size = 695277, upload-time = "2025-10-09T07:08:27.705Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6b/80f446103df39fc1ce6e271e7c274687bb830402de23e68795ba2ab13214/regress-2025.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47c03bfd853651241fc11436fb14ef7c9b312bb9a9c2828aa5c93943e945f1ef", size = 690951, upload-time = "2025-10-09T07:08:29.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/43cd990934ddb2be3318380fa380f7ca19e0eda0c30793b73e8ab8839367/regress-2025.10.1-cp313-cp313-win32.whl", hash = "sha256:3d12e6a834ed5f6d9dc7e86ea8fd77d37bb13900129930151d87c3261c3a799e", size = 282592, upload-time = "2025-10-09T07:08:30.695Z" }, + { url = "https://files.pythonhosted.org/packages/ab/22/4855af0447c98793d3a406ddf42680736e700b8c78336d6199dff9a5fd1b/regress-2025.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:ee3b7325ea849097d674020c72b2e6deb8f1018f085a7ecbd22831cd217b7f80", size = 301551, upload-time = "2025-10-09T07:08:31.715Z" }, + { url = "https://files.pythonhosted.org/packages/82/99/cd16dce5d1cb98970a8d7c577529e5890d938b54ade223d532fad0343221/regress-2025.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:e5f35e04fe6382c236d60c98b3f0a4a22dea75398b99c9c9bf3fb9d386cd7ebb", size = 288909, upload-time = "2025-10-09T07:08:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/a3/32/35dbbdff29174dcc253bb00b2a0d2aaf3f47a2b1d174ab4ab828feca63f1/regress-2025.10.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:0ee12c4e1c4e6e3609f41dd58065bc241945e912772f7320238d6544f0745950", size = 444440, upload-time = "2025-10-09T07:08:33.635Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1b/d97cf19b3de5ff8d78d03e0c470af48f7e8b6814b197014fda3f8a3aaca5/regress-2025.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:389c2278848cbfed81753a04ce8ea6c037271179cb9ef4decac7d3c65ae3330b", size = 434626, upload-time = "2025-10-09T07:08:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/04/f1/c0900448a4fd6248cd05c04c83aebb31f22ba3886965a5667f6ac4edc2b0/regress-2025.10.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf743b00cac20f8e8d271f275df7bc192ebb4faa7aee8fe98484df338786dec6", size = 514470, upload-time = "2025-10-09T07:08:35.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/a1/6346a314230af2859d2f0af5c4534ee497b9b13983ee06489652d7e1895b/regress-2025.10.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5541da1f581377bc20d2f77e017453aa8f2c2f4bfe7679dee00e139ec700abe8", size = 497546, upload-time = "2025-10-09T07:08:36.739Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/09605137dff09594c9e12ce555a80d8405a6ad76faa1db702bfba57468d0/regress-2025.10.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a67216efa72bb27db170f637d67eb9acdc167da5d617163b057803de7aed1e6d", size = 676318, upload-time = "2025-10-09T07:08:37.72Z" }, + { url = "https://files.pythonhosted.org/packages/26/37/4a65635c18844d687f9ad5bdd79b72fac8d15b131264616d6b1bb52614c2/regress-2025.10.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb61e653a325aea4681cf7b96ba9bbabc1aeb3f3d8fe877a07800024907398b9", size = 576190, upload-time = "2025-10-09T07:08:38.782Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/90a0a77a0416d7e3255e606f848021949b2f53ca18c9e4e240edb42e0929/regress-2025.10.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2a0a7da1d42fdff8068ab976a66beea572621514a130b37592489ae134a0e27", size = 507768, upload-time = "2025-10-09T07:08:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/82c8147027f0012dd7e743267d60dcf4218b2e061825b89cddddaa7fbe49/regress-2025.10.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:337ef62f785dc6e05c4a09be7a902980cf4d4f15346f4eca9eaf02745485440c", size = 520555, upload-time = "2025-10-09T07:08:40.801Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/5dd5dda048173b644a475ae9f4643733b31c3543e861569af4257c60d32a/regress-2025.10.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:aaf5d102e05b109dde13363e705969b3ffdbbfeb880270187eed0871e75f0c8f", size = 695587, upload-time = "2025-10-09T07:08:42.136Z" }, + { url = "https://files.pythonhosted.org/packages/9e/08/7f6615d8ce3088feb82843ff4d48191600602d624021dbef797d274a043d/regress-2025.10.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:adf331c8938e0d8705fc5d05d08fab09c11cb4bcdf8a64fa21902972c4cd38f4", size = 694933, upload-time = "2025-10-09T07:08:43.171Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/94524b996745f892b30dec6e770701956f7125459c6a6b2c075af4b8f0a6/regress-2025.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:287f86b5c0bf3bc9c0abd45bf6745ba9c6a5624c3132b07631bac4403b45143f", size = 691029, upload-time = "2025-10-09T07:08:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/64/e1/612c3b8afa448747e9133a95b1e18d0ee8f3f5e930f7048d1af03976f883/regress-2025.10.1-cp313-cp313t-win32.whl", hash = "sha256:877e05e7c570ee1e077e8b587cca8a318b7675f3c94c6c4e25d0d145abf7c0b6", size = 282136, upload-time = "2025-10-09T07:08:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/51/1d/9d5a476a6b16a41e724e864afb834947323bfa4b309978dbdc2bf8dbdb02/regress-2025.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:97307f87b128389d8b3f385c8e431fc318263281d1a1c0394606bc813aea05fc", size = 301674, upload-time = "2025-10-09T07:08:46.513Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -766,6 +877,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "ruff" version = "0.16.1"