mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
feat(ci): add global hash skip planning (#7185)
* feat(ci): add global hash skip planning * refactor(ci): remove superseded hash skip cache * refactor(ci): keep scope trust in planner * fix(ci): run scope goldens for planner changes Generated-By: looper 0.11.2 (runner=fixer, agent=codex) * fix(ci): publish hash state after successful validation Generated-By: looper 0.11.2 (runner=fixer, agent=codex) --------- Co-authored-by: Looper <looper@noreply.github.com>
This commit is contained in:
+24
-7
@@ -11,8 +11,9 @@ Before changing GitHub automation, read the current versions of:
|
||||
- `.github/workflows/autofix.atom.yml`
|
||||
- `.github/workflows/report.atom.yml`
|
||||
- `.github/scripts/handoff.py`
|
||||
- `scripts/scopes.ts`
|
||||
- `specs/current/ci.md` when changing scope rules, confidence tiers, or guards
|
||||
- `.github/config/runners.json`, `.github/config/scopes.json`, and `.github/config/hash.json`
|
||||
- `.github/scripts/runners.py`, `.github/scripts/scopes.py`, and `.github/scripts/hash.py`
|
||||
- `specs/current/ci.md` when changing scope rules, confidence tiers, or planner invariants
|
||||
- `e2e/tests/packaged-smoke-workflow.test.ts`
|
||||
- `scripts/approve-fork-pr-workflows.ts` and `e2e/tests/scripts/approve-fork-pr-workflows.test.ts` when touching fork PR approval behavior
|
||||
|
||||
@@ -26,7 +27,7 @@ Business layer:
|
||||
|
||||
- Business workflows decide what happened and what should be requested next.
|
||||
- `ci.yml` is the main low-privilege PR, merge-queue, and manual validation gate (application merge bar only).
|
||||
- `ci.yml` should run validation, decide scopes, and produce typed handoff artifacts.
|
||||
- `ci.yml` should resolve runners, compose scope and hash decisions in its Linux `plan` job, run validation, and produce typed handoff artifacts.
|
||||
- Packaging checks are standalone and outside the merge gate: `nix.yml` (flake check) and `docker-image.yml` (image validate + publish). Do not re-attach them to `Validate workspace`.
|
||||
- Business workflows should not perform trusted writes to PR comments or branches when a capability workflow can do it.
|
||||
|
||||
@@ -51,6 +52,21 @@ Default rule: do not add a new domain-specific follow-on workflow such as `foo.c
|
||||
|
||||
New workflow-owned helpers should usually live under `.github/scripts/`. Prefer TypeScript for project-owned scripts in general, but Python is acceptable for small GitHub runner glue when stdlib portability and low setup cost matter. Keep such exceptions narrow and covered by `pnpm guard` policy.
|
||||
|
||||
The CI control plane is deliberately Linux-only and stdlib-only. Runner classes,
|
||||
scope rules, and hash declarations live in `.github/config/`; their Python
|
||||
entrypoints initialize metadata before workload runners start. A Windows job
|
||||
must never invoke these scripts. Keep the four layers independent: runner
|
||||
placement, changed-file scopes, input hashes, and fine-grained commands inside
|
||||
a workload.
|
||||
|
||||
`hash.py` is a static comparison register, not a success cache. It reads the
|
||||
previous identity-to-hash map restored by Actions cache, computes the current
|
||||
map from Git inputs, and replaces the local state immediately. The plan carries
|
||||
that pending map to `validate`, which publishes it only after the gate succeeds;
|
||||
a failed run therefore cannot authorize identical-input skips on a fresh retry.
|
||||
Only a workload's YAML `if` gives the comparison skip semantics; cache loss or
|
||||
corruption starts cold.
|
||||
|
||||
## Handoff contract
|
||||
|
||||
Use `.github/scripts/handoff.py` for all CI follow-on artifact names and paths. The canonical layout is:
|
||||
@@ -125,13 +141,14 @@ Keep `.github/workflows/ci.yml` as the only approved workflow path unless a main
|
||||
- Same-repo patch: produce `handoff/autofix` and let `autofix.atom.yml` consume it.
|
||||
- Rich/generated comment: produce `handoff/report` and let `report.atom.yml` materialize and upsert it.
|
||||
- New naming, paths, or metadata: update `.github/scripts/handoff.py`.
|
||||
2. Update scope routing in `scripts/scopes.ts` when a workflow/script should trigger a validation lane.
|
||||
3. Update topology coverage in `e2e/tests/packaged-smoke-workflow.test.ts` or the relevant script test.
|
||||
4. Run the focused checks:
|
||||
2. Update scope routing in `.github/config/scopes.json`, then run `python3 .github/scripts/scopes.py validate`.
|
||||
3. Declare workload input closure in `.github/config/hash.json`; use `"*"` until a narrower set has high-confidence evidence.
|
||||
4. Update topology coverage in `e2e/tests/packaged-smoke-workflow.test.ts` or the relevant script test.
|
||||
5. Run the focused checks:
|
||||
- `python3 .github/scripts/handoff.py self-check`
|
||||
- `actionlint -color`
|
||||
- `pnpm --filter @open-design/e2e test tests/packaged-smoke-workflow.test.ts`
|
||||
5. Run repo-level checks before handing off:
|
||||
6. Run repo-level checks before handing off:
|
||||
- `pnpm guard`
|
||||
- `pnpm typecheck`
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Hash skip
|
||||
description: Skip a caller-defined unit when its declared input hash has already completed successfully
|
||||
|
||||
inputs:
|
||||
hash_skip:
|
||||
description: Declaration key under .github/hash_skip
|
||||
required: true
|
||||
mode:
|
||||
description: normal, verify, quarantine, or complete
|
||||
required: false
|
||||
default: normal
|
||||
|
||||
outputs:
|
||||
hash:
|
||||
value: ${{ steps.resolve.outputs.hash }}
|
||||
reason:
|
||||
value: ${{ inputs.mode == 'complete' && 'complete' || inputs.mode == 'quarantine' && 'quarantine' || inputs.mode == 'verify' && 'verify' || steps.restore.outputs.cache-hit == 'true' && 'hit' || 'miss' }}
|
||||
run:
|
||||
value: ${{ inputs.mode != 'complete' && (inputs.mode != 'normal' || steps.restore.outputs.cache-hit != 'true') }}
|
||||
skip:
|
||||
value: ${{ inputs.mode == 'normal' && steps.restore.outputs.cache-hit == 'true' }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve declared hash
|
||||
id: resolve
|
||||
shell: bash
|
||||
run: node --experimental-strip-types .github/scripts/hash_skip.ts "${{ inputs.hash_skip }}" --mode "${{ inputs.mode }}"
|
||||
- name: Resolve successful execution cache
|
||||
id: restore
|
||||
if: ${{ inputs.mode != 'quarantine' && inputs.mode != 'complete' }}
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
enableCrossOsArchive: true
|
||||
key: ${{ steps.resolve.outputs.cache_key }}
|
||||
path: ${{ steps.resolve.outputs.marker }}
|
||||
- name: Register successful execution cache
|
||||
if: ${{ inputs.mode == 'complete' }}
|
||||
uses: actions/cache/save@v5.0.5
|
||||
with:
|
||||
enableCrossOsArchive: true
|
||||
key: ${{ steps.resolve.outputs.cache_key }}
|
||||
path: ${{ steps.resolve.outputs.marker }}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schema": { "version": 1 },
|
||||
"suites": {
|
||||
"ci-control": [
|
||||
".github/config/hash.json",
|
||||
".github/config/scopes.json",
|
||||
".github/scripts/hash.py",
|
||||
".github/scripts/scopes.py",
|
||||
".github/scripts/lib/",
|
||||
".github/workflows/ci.yml"
|
||||
],
|
||||
"web": [
|
||||
"apps/web/", "packages/components/", "packages/contracts/", "packages/host/",
|
||||
"packages/platform/", "packages/release/", "packages/sidecar/", "packages/sidecar-proto/",
|
||||
"package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"
|
||||
],
|
||||
"tools-pack": [
|
||||
"tools/pack/", "apps/packaged/", "apps/desktop/", "packages/components/", "packages/host/",
|
||||
"packages/platform/", "packages/release/", "packages/sidecar/", "packages/sidecar-proto/",
|
||||
"package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml"
|
||||
],
|
||||
"ui": [
|
||||
"suite://web", "apps/daemon/", "e2e/", ".github/actions/setup-playwright/",
|
||||
".github/actions/setup-workspace/"
|
||||
]
|
||||
},
|
||||
"workflows": {
|
||||
"ci": {
|
||||
"static_gate": ["*"],
|
||||
"preflight": ["*"],
|
||||
"workspace_unit_tests": ["*"],
|
||||
"daemon_unit_tests": ["*"],
|
||||
"windows_tools_pack_payload_tests": ["*"],
|
||||
"web_workspace_tests": ["*"],
|
||||
"e2e_vitest": ["*"],
|
||||
"playwright_critical": ["*"],
|
||||
"ui_p0": ["*"],
|
||||
"playwright_visual": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"schema": { "version": 1 },
|
||||
"defaultMode": "default",
|
||||
"aliases": { "performance": "default" },
|
||||
"profiles": {
|
||||
"github.ubuntu": ["ubuntu-24.04"],
|
||||
"github.windows": ["windows-latest"],
|
||||
"blacksmith.small": ["blacksmith-4vcpu-ubuntu-2404"],
|
||||
"blacksmith.large": ["blacksmith-8vcpu-ubuntu-2404"],
|
||||
"nexu.small": ["nexu-runners-small"],
|
||||
"nexu.medium": ["nexu-runners-medium"],
|
||||
"nexu.large": ["nexu-runners-large"],
|
||||
"nexu.xlarge": ["nexu-runners-xlarge"]
|
||||
},
|
||||
"modes": {
|
||||
"default": {
|
||||
"control": "nexu.small",
|
||||
"general_medium": "nexu.medium",
|
||||
"workspace_unit": "nexu.medium",
|
||||
"windows_tools": "github.windows",
|
||||
"js_hot": "nexu.medium",
|
||||
"ui_hot": "nexu.large",
|
||||
"ui_p0": "nexu.medium",
|
||||
"ui_p0_heavy": "nexu.xlarge",
|
||||
"visual_hot": "nexu.large"
|
||||
},
|
||||
"economic": {
|
||||
"control": "github.ubuntu",
|
||||
"general_medium": "github.ubuntu",
|
||||
"workspace_unit": "github.ubuntu",
|
||||
"windows_tools": "github.windows",
|
||||
"js_hot": "github.ubuntu",
|
||||
"ui_hot": "github.ubuntu",
|
||||
"ui_p0": "github.ubuntu",
|
||||
"ui_p0_heavy": "github.ubuntu",
|
||||
"visual_hot": "github.ubuntu"
|
||||
},
|
||||
"blacksmith": {
|
||||
"control": "blacksmith.small",
|
||||
"general_medium": "blacksmith.small",
|
||||
"workspace_unit": "blacksmith.small",
|
||||
"windows_tools": "github.windows",
|
||||
"js_hot": "blacksmith.small",
|
||||
"ui_hot": "blacksmith.large",
|
||||
"ui_p0": "blacksmith.large",
|
||||
"ui_p0_heavy": "blacksmith.large",
|
||||
"visual_hot": "blacksmith.large"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"schema": { "version": 1 },
|
||||
"effects": [
|
||||
"daemon_tests_required",
|
||||
"web_tests_required",
|
||||
"tools_dev_tests_required",
|
||||
"tools_pack_tests_required",
|
||||
"ui_critical_validation_required",
|
||||
"ui_p0_validation_required",
|
||||
"visual_validation_required",
|
||||
"workspace_validation_required"
|
||||
],
|
||||
"matches": {
|
||||
"daemon-runtime-definition": {
|
||||
"prefixes": ["apps/daemon/src/runtimes/defs/"],
|
||||
"exact": [
|
||||
"apps/daemon/src/runtimes/capabilities.ts",
|
||||
"apps/daemon/src/runtimes/local-profiles.ts",
|
||||
"apps/daemon/src/runtimes/metadata.ts",
|
||||
"apps/daemon/src/runtimes/registry.ts",
|
||||
"apps/daemon/tests/runtimes/agent-args.test.ts",
|
||||
"apps/daemon/tests/runtimes/antigravity-model-lock.test.ts",
|
||||
"apps/daemon/tests/runtimes/atomcode.test.ts",
|
||||
"apps/daemon/tests/runtimes/byok-opencode.test.ts",
|
||||
"apps/daemon/tests/runtimes/chat-run-inactivity-timeout.test.ts",
|
||||
"apps/daemon/tests/runtimes/claude-resume-args.test.ts",
|
||||
"apps/daemon/tests/runtimes/codebuddy.test.ts",
|
||||
"apps/daemon/tests/runtimes/codex-resume-args.test.ts",
|
||||
"apps/daemon/tests/runtimes/detection-resilience.test.ts",
|
||||
"apps/daemon/tests/runtimes/opencode-resume-args.test.ts",
|
||||
"apps/daemon/tests/runtimes/registry-and-args.test.ts",
|
||||
"apps/daemon/tests/runtimes/trae-cli.test.ts"
|
||||
]
|
||||
},
|
||||
"certain-exempt": {
|
||||
"prefixes": [".vscode/", ".idea/", "docs/", "apps/landing-page/", ".github/ISSUE_TEMPLATE/"],
|
||||
"exact": ["LICENSE", ".github/CODEOWNERS"],
|
||||
"exclude": ["match://daemon-doc"]
|
||||
},
|
||||
"daemon-doc": { "exact": ["docs/agent-adapters.md"] },
|
||||
"packaged-leaf": {
|
||||
"prefixes": [
|
||||
"apps/desktop/src/", "apps/desktop/tests/", "apps/packaged/src/", "apps/packaged/tests/",
|
||||
"tools/pack/src/", "tools/pack/tests/", "tools/pack/resources/"
|
||||
]
|
||||
},
|
||||
"daemon-core": {
|
||||
"prefixes": ["apps/daemon/src/", "apps/daemon/tests/"],
|
||||
"exact": ["docs/agent-adapters.md"],
|
||||
"exclude": ["match://daemon-runtime-definition", "prefix://apps/daemon/src/sidecar/"]
|
||||
},
|
||||
"medium-exempt": {
|
||||
"prefixes": ["nix/"],
|
||||
"exact": [
|
||||
".gitignore", ".editorconfig", "flake.nix", "flake.lock",
|
||||
".github/workflows/landing-page-ci.yml", ".github/workflows/landing-page-staging.yml",
|
||||
".github/workflows/landing-page-production.yml", ".github/workflows/blog-indexing-on-deploy.yml",
|
||||
".github/workflows/autofix.atom.yml", ".github/workflows/comment.atom.yml",
|
||||
".github/workflows/report.atom.yml", ".github/workflows/docker-image.yml", ".github/workflows/nix.yml"
|
||||
],
|
||||
"regexes": ["\\.(?:md|mdx|txt)$"],
|
||||
"exclude": ["match://certain-surface"]
|
||||
},
|
||||
"certain-surface": {
|
||||
"include": ["match://certain-exempt", "match://packaged-leaf", "match://daemon-core"]
|
||||
},
|
||||
"workspace-exempt": {
|
||||
"include": ["match://certain-exempt", "match://medium-exempt", "match://packaged-leaf", "match://daemon-core"]
|
||||
},
|
||||
"ui-critical-exempt": {
|
||||
"include": ["match://certain-exempt", "match://medium-exempt", "match://daemon-core"],
|
||||
"prefixes": ["apps/desktop/", "apps/packaged/", "tools/pack/"]
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "certain-exempt-surface", "match": { "include": ["match://certain-exempt"] },
|
||||
"effects": [], "confidence": "certain"
|
||||
},
|
||||
{
|
||||
"id": "exempt-surface", "match": { "include": ["match://medium-exempt"] },
|
||||
"effects": [], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "certain-daemon-core", "match": { "include": ["match://daemon-core"] },
|
||||
"effects": ["daemon_tests_required", "ui_critical_validation_required", "ui_p0_validation_required", "workspace_validation_required"],
|
||||
"confidence": "certain"
|
||||
},
|
||||
{
|
||||
"id": "daemon-sources",
|
||||
"match": {
|
||||
"prefixes": ["apps/daemon/", "packages/release/", "packages/contracts/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/"],
|
||||
"exclude": ["match://daemon-core"]
|
||||
},
|
||||
"effects": ["daemon_tests_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "web-sources",
|
||||
"match": { "prefixes": ["apps/web/", "packages/release/", "packages/components/", "packages/contracts/", "packages/host/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/"] },
|
||||
"effects": ["web_tests_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "runtime-content",
|
||||
"match": { "prefixes": ["scripts/", "assets/", "skills/", "prompt-templates/", "design-systems/", "design-templates/", "craft/"] },
|
||||
"effects": ["daemon_tests_required", "web_tests_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "script-contract-tests", "match": { "prefixes": ["e2e/tests/scripts/"] },
|
||||
"effects": ["daemon_tests_required", "web_tests_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "tools-dev-sources",
|
||||
"match": { "prefixes": ["tools/dev/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/"] },
|
||||
"effects": ["tools_dev_tests_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "certain-packaged-leaf-sources", "match": { "include": ["match://packaged-leaf"] },
|
||||
"effects": ["tools_dev_tests_required", "tools_pack_tests_required", "workspace_validation_required"],
|
||||
"confidence": "certain"
|
||||
},
|
||||
{
|
||||
"id": "tools-pack-sources",
|
||||
"match": {
|
||||
"prefixes": ["tools/pack/", "apps/packaged/", "apps/desktop/", "packages/release/", "packages/components/", "packages/host/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/"],
|
||||
"exclude": ["match://packaged-leaf"]
|
||||
},
|
||||
"effects": ["tools_pack_tests_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "workspace-manifests-and-ci",
|
||||
"match": {
|
||||
"exact": ["package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".github/workflows/ci.yml", "e2e/package.json"],
|
||||
"regexes": ["^apps/[^/]+/package\\.json$", "^packages/[^/]+/package\\.json$", "^tools/[^/]+/package\\.json$"]
|
||||
},
|
||||
"effects": ["daemon_tests_required", "web_tests_required", "tools_dev_tests_required", "tools_pack_tests_required"],
|
||||
"confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ui-p0-surface",
|
||||
"match": {
|
||||
"prefixes": ["apps/web/", "apps/daemon/", "packages/release/", "packages/components/", "packages/contracts/", "packages/host/", "packages/platform/", "packages/sidecar/", "packages/sidecar-proto/", "e2e/ui/", "e2e/lib/", "e2e/resources/", "e2e/scripts/", ".github/actions/setup-playwright/", ".github/actions/setup-workspace/"],
|
||||
"exact": ["e2e/package.json", "e2e/playwright.config.ts", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", ".github/workflows/ci.yml", ".github/workflows/ui-extended-main.yml"],
|
||||
"exclude": ["match://daemon-core"]
|
||||
},
|
||||
"effects": ["ui_p0_validation_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "visual-surface",
|
||||
"match": {
|
||||
"prefixes": ["apps/web/", "e2e/lib/playwright/", ".github/actions/setup-playwright/", ".github/actions/setup-workspace/"],
|
||||
"exact": ["e2e/package.json", "e2e/playwright.visual.config.ts", "e2e/scripts/playwright.ts", "e2e/scripts/visual-report.ts", "pnpm-lock.yaml", ".github/scripts/handoff.py", ".github/workflows/ci.yml", ".github/workflows/comment.atom.yml", ".github/workflows/report.atom.yml", ".github/workflows/visual-baseline.yml"],
|
||||
"regexes": ["^e2e/ui/visual-[^/]+\\.test\\.ts$"]
|
||||
},
|
||||
"effects": ["visual_validation_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ci-control-plane",
|
||||
"match": {
|
||||
"prefixes": [".github/config/"],
|
||||
"exact": [
|
||||
".github/scripts/scopes.py", ".github/scripts/hash.py", ".github/scripts/runners.py",
|
||||
".github/scripts/lib/config.py", ".github/scripts/lib/github.py"
|
||||
]
|
||||
},
|
||||
"effects": ["web_tests_required", "workspace_validation_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ci-rerun-infra-cancel-surface",
|
||||
"match": { "exact": [".github/workflows/rerun.atom.yml", ".github/scripts/rerun_infra_cancel.py", "e2e/tests/packaged-smoke-workflow.test.ts"] },
|
||||
"effects": ["web_tests_required", "workspace_validation_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "workspace-fallback", "match": { "exclude": ["match://workspace-exempt"] },
|
||||
"effects": ["workspace_validation_required"], "confidence": "medium"
|
||||
},
|
||||
{
|
||||
"id": "ui-critical-fallback", "match": { "exclude": ["match://ui-critical-exempt"] },
|
||||
"effects": ["ui_critical_validation_required"], "confidence": "medium"
|
||||
}
|
||||
],
|
||||
"matrices": {
|
||||
"ui_p0": [
|
||||
{ "name": "entry-settings", "shard": "entry-settings" },
|
||||
{ "name": "project-workspace", "shard": "project-workspace" },
|
||||
{ "name": "project-workspace-editor", "shard": "project-workspace-editor" },
|
||||
{ "name": "project-collab", "shard": "project-collab" },
|
||||
{ "name": "project-runtime", "shard": "project-runtime" },
|
||||
{ "name": "workspace-restoration", "shard": "workspace-restoration" }
|
||||
],
|
||||
"visual": [
|
||||
{ "name": "entry-navigation", "files": "ui/visual-entry.test.ts ui/visual-navigation.test.ts" },
|
||||
{ "name": "settings-workspace", "files": "ui/visual-settings.test.ts ui/visual-workspace.test.ts" }
|
||||
]
|
||||
},
|
||||
"uiP0Shadow": {
|
||||
"match": "daemon-runtime-definition",
|
||||
"matrixNames": ["entry-settings", "project-workspace", "project-collab", "project-runtime"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from lib.config import ConfigError, compact_json, load_json, object_value, repository_root, schema_v1
|
||||
from lib.github import append_outputs, append_summary
|
||||
|
||||
|
||||
PROTOCOL = "nexu-hash-v1"
|
||||
CONTROL_SUITE = "ci-control"
|
||||
|
||||
|
||||
class HashContract:
|
||||
def __init__(self, path):
|
||||
value = object_value(load_json(path), "hash")
|
||||
if set(value) != {"schema", "suites", "workflows"}:
|
||||
raise ConfigError("hash keys must be schema, suites, and workflows")
|
||||
schema_v1(value, "hash")
|
||||
self.suites = object_value(value["suites"], "hash.suites")
|
||||
self.workflows = object_value(value["workflows"], "hash.workflows")
|
||||
if CONTROL_SUITE not in self.suites:
|
||||
raise ConfigError(f"hash.suites must define {CONTROL_SUITE}")
|
||||
self._validate()
|
||||
|
||||
@staticmethod
|
||||
def _tokens(value, label):
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ConfigError(f"{label} must be a non-empty array")
|
||||
if any(not isinstance(token, str) or not token for token in value):
|
||||
raise ConfigError(f"{label} contains an invalid token")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _validate_path(token, label):
|
||||
if token == "*":
|
||||
return
|
||||
if token.startswith(("/", "~")) or "\\" in token or "\n" in token:
|
||||
raise ConfigError(f"{label} has unsafe path token {token!r}")
|
||||
if ".." in PurePosixPath(token).parts:
|
||||
raise ConfigError(f"{label} escapes the repository: {token!r}")
|
||||
if "://" in token:
|
||||
raise ConfigError(f"{label} has unsupported token scheme: {token}")
|
||||
|
||||
def _validate(self):
|
||||
nodes = {}
|
||||
for suite, raw in self.suites.items():
|
||||
if not isinstance(suite, str) or not suite:
|
||||
raise ConfigError("hash.suites keys must be non-empty strings")
|
||||
nodes[f"suite://{suite}"] = self._tokens(raw, f"hash.suites.{suite}")
|
||||
for workflow, identities in self.workflows.items():
|
||||
if not isinstance(workflow, str) or not workflow or "/" in workflow:
|
||||
raise ConfigError("hash.workflows keys must be non-empty strings")
|
||||
identities = object_value(identities, f"hash.workflows.{workflow}")
|
||||
if not identities:
|
||||
raise ConfigError(f"hash.workflows.{workflow} must not be empty")
|
||||
for identity, raw in identities.items():
|
||||
if not isinstance(identity, str) or not identity or "/" in identity:
|
||||
raise ConfigError(f"hash.workflows.{workflow} has an invalid identity")
|
||||
nodes[f"key://{workflow}/{identity}"] = self._tokens(raw, f"hash.workflows.{workflow}.{identity}")
|
||||
for node, tokens in nodes.items():
|
||||
for token in tokens:
|
||||
if token.startswith(("suite://", "key://")):
|
||||
if token not in nodes:
|
||||
raise ConfigError(f"{node} references unknown {token}")
|
||||
else:
|
||||
self._validate_path(token, node)
|
||||
visiting, complete = [], set()
|
||||
|
||||
def visit(node):
|
||||
if node in visiting:
|
||||
raise ConfigError(f"hash dependency cycle: {' -> '.join((*visiting, node))}")
|
||||
if node in complete:
|
||||
return
|
||||
visiting.append(node)
|
||||
tokens = nodes[node]
|
||||
if node.startswith("key://"):
|
||||
tokens = [f"suite://{CONTROL_SUITE}", *tokens]
|
||||
for token in tokens:
|
||||
if token in nodes:
|
||||
visit(token)
|
||||
visiting.pop()
|
||||
complete.add(node)
|
||||
|
||||
for node in nodes:
|
||||
visit(node)
|
||||
|
||||
def declarations(self, workflow):
|
||||
if workflow not in self.workflows:
|
||||
raise ConfigError(f"unknown hash workflow: {workflow}")
|
||||
return self.workflows[workflow]
|
||||
|
||||
|
||||
class GitFingerprinter:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.cache = {}
|
||||
|
||||
def records(self, token):
|
||||
if token in self.cache:
|
||||
return self.cache[token]
|
||||
if token == "*":
|
||||
pathspec = []
|
||||
elif any(character in token for character in "*?["):
|
||||
pathspec = [f":(glob){token}"]
|
||||
else:
|
||||
pathspec = [token]
|
||||
command = ["git", "ls-files", "-s", "-z"]
|
||||
if pathspec:
|
||||
command += ["--", *pathspec]
|
||||
result = subprocess.run(command, cwd=self.root, check=True, stdout=subprocess.PIPE)
|
||||
records = []
|
||||
for raw in result.stdout.split(b"\0"):
|
||||
if not raw:
|
||||
continue
|
||||
metadata, path = raw.split(b"\t", 1)
|
||||
mode, oid, stage = metadata.decode("ascii").split()
|
||||
records.append((path.decode("utf-8", "surrogateescape"), mode, oid, stage))
|
||||
records.sort()
|
||||
if not records:
|
||||
raise ConfigError(f"hash path token matched no tracked files: {token}")
|
||||
self.cache[token] = records
|
||||
return records
|
||||
|
||||
|
||||
def digest_node(contract, fingerprinter, node, tokens, resolved):
|
||||
if node in resolved:
|
||||
return resolved[node]
|
||||
if node.startswith("key://"):
|
||||
tokens = [f"suite://{CONTROL_SUITE}", *tokens]
|
||||
digest = hashlib.sha256()
|
||||
digest.update(f"{PROTOCOL}\0{node}\0".encode())
|
||||
for token in tokens:
|
||||
digest.update(f"token\0{token}\0".encode())
|
||||
if token.startswith("suite://"):
|
||||
name = token.removeprefix("suite://")
|
||||
child = digest_node(contract, fingerprinter, token, contract.suites[name], resolved)
|
||||
digest.update(f"digest\0{child}\0".encode())
|
||||
elif token.startswith("key://"):
|
||||
workflow, identity = token.removeprefix("key://").split("/", 1)
|
||||
child = digest_node(contract, fingerprinter, token, contract.workflows[workflow][identity], resolved)
|
||||
digest.update(f"digest\0{child}\0".encode())
|
||||
else:
|
||||
for path, mode, oid, stage in fingerprinter.records(token):
|
||||
digest.update(f"file\0{path}\0{mode}\0{oid}\0{stage}\0".encode("utf-8", "surrogateescape"))
|
||||
resolved[node] = digest.hexdigest()
|
||||
return resolved[node]
|
||||
|
||||
|
||||
def calculate(contract, root, workflow):
|
||||
resolved = {}
|
||||
fingerprinter = GitFingerprinter(root)
|
||||
hashes = {}
|
||||
for identity, declared in contract.declarations(workflow).items():
|
||||
node = f"key://{workflow}/{identity}"
|
||||
# Control inputs are implicit so later optimized declarations cannot
|
||||
# accidentally make their own planner/configuration changes invisible.
|
||||
hashes[identity] = digest_node(contract, fingerprinter, node, declared, resolved)
|
||||
return hashes
|
||||
|
||||
|
||||
def read_previous(path, workflow):
|
||||
try:
|
||||
value = load_json(path)
|
||||
if value.get("schemaVersion") != 1 or value.get("workflow") != workflow or not isinstance(value.get("hashes"), dict):
|
||||
raise ConfigError("state contract differs")
|
||||
if any(not isinstance(key, str) or not isinstance(digest, str) for key, digest in value["hashes"].items()):
|
||||
raise ConfigError("state hashes are invalid")
|
||||
return value["hashes"], None
|
||||
except (ConfigError, AttributeError) as error:
|
||||
return {}, str(error)
|
||||
|
||||
|
||||
def write_state(path, workflow, hashes):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"schemaVersion": 1, "workflow": workflow, "hashes": hashes}
|
||||
handle, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent), text=True)
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8") as output:
|
||||
json.dump(payload, output, indent=2, sort_keys=True)
|
||||
output.write("\n")
|
||||
os.replace(temporary, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def execute(root, contract, workflow, scope_plan_path, state_path):
|
||||
plan = load_json(scope_plan_path)
|
||||
enabled = object_value(plan.get("enabled"), "scope plan.enabled")
|
||||
identities = set(contract.declarations(workflow))
|
||||
if set(enabled) != identities:
|
||||
raise ConfigError(f"scope/hash identity mismatch (scope={sorted(enabled)}, hash={sorted(identities)})")
|
||||
if any(not isinstance(value, bool) for value in enabled.values()):
|
||||
raise ConfigError("scope plan.enabled values must be booleans")
|
||||
previous, state_warning = read_previous(state_path, workflow) if state_path.exists() else ({}, "state missing")
|
||||
current = calculate(contract, root, workflow)
|
||||
equal = {identity: previous.get(identity) == digest for identity, digest in current.items()}
|
||||
run = {identity: bool(enabled[identity]) and not equal[identity] for identity in current}
|
||||
reasons = {
|
||||
identity: "scope-disabled" if not enabled[identity] else "hash-equal" if equal[identity] else "hash-changed"
|
||||
for identity in current
|
||||
}
|
||||
write_state(state_path, workflow, current)
|
||||
append_outputs({"run": compact_json(run), "equal": compact_json(equal)})
|
||||
lines = ["### Hash decisions", "", "| Identity | Scope | Equal | Run | Reason |", "| --- | ---: | ---: | ---: | --- |"]
|
||||
for identity in current:
|
||||
lines.append(f"| {identity} | {str(bool(enabled[identity])).lower()} | {str(equal[identity]).lower()} | {str(run[identity]).lower()} | {reasons[identity]} |")
|
||||
if state_warning:
|
||||
lines += ["", f"> Previous state unavailable ({state_warning}); identities start cold."]
|
||||
append_summary("\n".join(lines))
|
||||
print(json.dumps({"run": run, "equal": equal, "reasons": reasons}, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", type=Path)
|
||||
parser.add_argument("--root", type=Path)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("validate")
|
||||
github = sub.add_parser("github-output")
|
||||
github.add_argument("--workflow", required=True)
|
||||
github.add_argument("--scope-plan", type=Path, required=True)
|
||||
github.add_argument("--state", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
root = args.root.resolve() if args.root else repository_root(__file__)
|
||||
contract = HashContract(args.config or root / ".github/config/hash.json")
|
||||
if args.command == "validate":
|
||||
for workflow in contract.workflows:
|
||||
calculate(contract, root, workflow)
|
||||
print("hash configuration is valid")
|
||||
return 0
|
||||
execute(root, contract, args.workflow, args.scope_plan, args.state)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (ConfigError, subprocess.SubprocessError, OSError) as error:
|
||||
print(f"hash configuration error: {error}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { ensure, fail } from "./lib/control.ts";
|
||||
import { git, gitPathSetDigest, type GitHashPath } from "./lib/git.ts";
|
||||
import { canonicalJson, digest, type Digest } from "./lib/json.ts";
|
||||
|
||||
type Declaration = Readonly<{
|
||||
extraDigests: readonly string[];
|
||||
hashPaths: readonly GitHashPath[];
|
||||
schemaVersion: number;
|
||||
}>;
|
||||
|
||||
const KEY_PATTERN = /^[a-z][a-z0-9.-]*$/u;
|
||||
const EXTRA_PATTERN = /^[a-z][a-z0-9]*$/u;
|
||||
const CONTROL_PATHS = [
|
||||
".github/actions/hash-skip/action.yml",
|
||||
".github/scripts/hash_skip.ts",
|
||||
".github/scripts/lib/control.ts",
|
||||
".github/scripts/lib/git.ts",
|
||||
".github/scripts/lib/json.ts",
|
||||
] as const;
|
||||
|
||||
function repoPath(value: unknown, label: string): string {
|
||||
const text = ensure.text(value, label).replaceAll("\\", "/").replace(/^\.\//u, "").replace(/\/+$/u, "");
|
||||
if (isAbsolute(text) || text === ".." || text.startsWith("../") || text.includes("/../")) {
|
||||
fail(`${label} must be repository-relative`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
const entries = ensure.array(value, label).map((entry, index) => ensure.text(entry, `${label}[${index}]`)).sort();
|
||||
ensure.that(new Set(entries).size === entries.length, `${label} must not contain duplicates`);
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
function hashPath(value: unknown, label: string): GitHashPath {
|
||||
if (typeof value === "string") return Object.freeze({ path: repoPath(value, label) });
|
||||
const input = ensure.record(value, label);
|
||||
ensure.exactKeys(input, ["excludeDirectoryNames", "excludePaths", "normalizePackageVersion", "normalizeTextLineEndings", "path"], label);
|
||||
for (const field of ["normalizePackageVersion", "normalizeTextLineEndings"] as const) {
|
||||
ensure.that(input[field] == null || typeof input[field] === "boolean", `${label}.${field} must be boolean`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...(input.excludeDirectoryNames == null ? {} : { excludeDirectoryNames: strings(input.excludeDirectoryNames, `${label}.excludeDirectoryNames`) }),
|
||||
...(input.excludePaths == null ? {} : { excludePaths: strings(input.excludePaths, `${label}.excludePaths`).map((entry) => repoPath(entry, `${label}.excludePaths`)) }),
|
||||
...(input.normalizePackageVersion === true ? { normalizePackageVersion: true } : {}),
|
||||
...(input.normalizeTextLineEndings === true ? { normalizeTextLineEndings: true } : {}),
|
||||
path: repoPath(input.path, `${label}.path`),
|
||||
});
|
||||
}
|
||||
|
||||
function declaration(root: string, key: string, ref: string): Declaration {
|
||||
ensure.that(KEY_PATTERN.test(key), `invalid hash_skip key: ${key}`);
|
||||
const path = `.github/hash_skip/${key}.json`;
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(git(["show", `${ref}:${path}`], root).toString("utf8")); } catch (error) {
|
||||
fail(`invalid hash_skip declaration ${key}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
const input = ensure.record(value, `hash_skip ${key}`);
|
||||
ensure.exactKeys(input, ["extraDigests", "hashPaths", "schemaVersion"], `hash_skip ${key}`);
|
||||
const extraDigests = strings(input.extraDigests, `hash_skip ${key}.extraDigests`);
|
||||
ensure.that(extraDigests.every((name) => EXTRA_PATTERN.test(name)), `hash_skip ${key}.extraDigests contains an invalid name`);
|
||||
const hashPaths = ensure.array(input.hashPaths, `hash_skip ${key}.hashPaths`).map((entry, index) => hashPath(entry, `hash_skip ${key}.hashPaths[${index}]`));
|
||||
ensure.that(hashPaths.length > 0, `hash_skip ${key}.hashPaths must not be empty`);
|
||||
return Object.freeze({ extraDigests, hashPaths: Object.freeze(hashPaths), schemaVersion: ensure.integer(input.schemaVersion, `hash_skip ${key}.schemaVersion`) });
|
||||
}
|
||||
|
||||
function extraDigests(definition: Declaration): Readonly<Record<string, Digest>> {
|
||||
return Object.freeze(Object.fromEntries(definition.extraDigests.map((name) => {
|
||||
const env = `HASH_SKIP_EXTRA_${name.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase()}`;
|
||||
return [name, ensure.digest(process.env[env], env)];
|
||||
})));
|
||||
}
|
||||
|
||||
function resolveKey(root: string, key: string, ref: string, mode = "normal"): Readonly<{ cacheKey: string; hash: Digest; marker: string }> {
|
||||
ensure.that(["complete", "normal", "quarantine", "verify"].includes(mode), "hash_skip mode must be complete, normal, quarantine, or verify");
|
||||
const commit = git(["rev-parse", "--verify", `${ref}^{commit}`], root).toString("utf8").trim();
|
||||
const definition = declaration(root, key, commit);
|
||||
const source = gitPathSetDigest(root, commit, definition.hashPaths, { domain: "open-design/hash-skip/source/v1", label: key });
|
||||
const control = gitPathSetDigest(root, commit, CONTROL_PATHS.map((path) => ({ path })), { domain: "open-design/hash-skip/control/v1", label: "hash_skip control" });
|
||||
const hash = digest(canonicalJson({ control, definition, domain: "open-design/hash-skip/v1", extras: extraDigests(definition), key, source }));
|
||||
const token = hash.slice("sha256:".length);
|
||||
const marker = `.tmp/hash_skip/${token}/success.json`;
|
||||
const absoluteMarker = join(root, marker);
|
||||
mkdirSync(dirname(absoluteMarker), { recursive: true });
|
||||
writeFileSync(absoluteMarker, `${JSON.stringify({ hash, key, schemaVersion: 1 })}\n`);
|
||||
return Object.freeze({ cacheKey: `hash-skip-v1-${key}-${token}`, hash, marker });
|
||||
}
|
||||
|
||||
function emit(result: ReturnType<typeof resolveKey>): void {
|
||||
const output = process.env.GITHUB_OUTPUT;
|
||||
if (output == null) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
else appendFileSync(output, `cache_key=${result.cacheKey}\nhash=${result.hash}\nmarker=${result.marker}\n`);
|
||||
}
|
||||
|
||||
function init(root: string): void {
|
||||
execFileSync("git", ["init", "-q"], { cwd: root });
|
||||
execFileSync("git", ["config", "user.email", "hash-skip@open-design.invalid"], { cwd: root });
|
||||
execFileSync("git", ["config", "user.name", "hash_skip self-check"], { cwd: root });
|
||||
}
|
||||
|
||||
function selfCheck(): void {
|
||||
const root = mkdtempSync(join(tmpdir(), "open-design-hash-skip-"));
|
||||
try {
|
||||
init(root);
|
||||
for (const path of CONTROL_PATHS) { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), `${path}\n`); }
|
||||
mkdirSync(join(root, ".github", "hash_skip"), { recursive: true });
|
||||
writeFileSync(join(root, ".github", "hash_skip", "probe.json"), JSON.stringify({ extraDigests: ["runtime"], hashPaths: ["source.txt"], schemaVersion: 1 }));
|
||||
writeFileSync(join(root, "source.txt"), "one\n");
|
||||
execFileSync("git", ["add", "."], { cwd: root }); execFileSync("git", ["commit", "-qm", "one"], { cwd: root });
|
||||
process.env.HASH_SKIP_EXTRA_RUNTIME = digest("node24");
|
||||
const first = resolveKey(root, "probe", "HEAD");
|
||||
ensure.that(first.marker.startsWith(".tmp/hash_skip/") && !isAbsolute(first.marker), "cache marker must be repository-relative and cross-runner portable");
|
||||
ensure.that(first.hash === resolveKey(root, "probe", "HEAD").hash, "same inputs produced different hashes");
|
||||
const completed = resolveKey(root, "probe", "HEAD", "complete");
|
||||
ensure.that(first.hash === completed.hash && first.cacheKey === completed.cacheKey && first.marker === completed.marker, "normal and complete modes produced asymmetric identities");
|
||||
writeFileSync(join(root, "source.txt"), "two\n"); execFileSync("git", ["add", "."], { cwd: root }); execFileSync("git", ["commit", "-qm", "two"], { cwd: root });
|
||||
const sourceChanged = resolveKey(root, "probe", "HEAD");
|
||||
ensure.that(first.hash !== sourceChanged.hash, "source change did not invalidate hash");
|
||||
process.env.HASH_SKIP_EXTRA_RUNTIME = digest("node25");
|
||||
ensure.that(sourceChanged.hash !== resolveKey(root, "probe", "HEAD").hash, "extra digest change did not invalidate hash");
|
||||
process.stdout.write("hash_skip self-check OK\n");
|
||||
} finally { rmSync(root, { force: true, recursive: true }); }
|
||||
}
|
||||
|
||||
const [key, ...args] = process.argv.slice(2);
|
||||
if (key == null || key === "--help") process.stdout.write("Usage: hash_skip.ts <key> [--root <path>] [--ref <git-ref>] | self-check\n");
|
||||
else if (key === "self-check") selfCheck();
|
||||
else {
|
||||
ensure.that(args.length % 2 === 0, "hash_skip options must be pairs");
|
||||
const options = Object.fromEntries(Array.from({ length: args.length / 2 }, (_, index) => [args[index * 2]!.replace(/^--/u, ""), args[index * 2 + 1]!]));
|
||||
ensure.exactKeys(options, ["mode", "ref", "root"], "hash_skip options");
|
||||
emit(resolveKey(resolve(options.root ?? process.cwd()), key, options.ref ?? "HEAD", options.mode ?? "normal"));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def repository_root(script_file: str) -> Path:
|
||||
return Path(script_file).resolve().parents[2]
|
||||
|
||||
|
||||
def load_json(path: Path):
|
||||
try:
|
||||
with path.open(encoding="utf-8") as source:
|
||||
return json.load(source)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ConfigError(f"cannot load {path}: {error}") from error
|
||||
|
||||
|
||||
def object_value(value, label: str):
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def array_value(value, label: str):
|
||||
if not isinstance(value, list):
|
||||
raise ConfigError(f"{label} must be an array")
|
||||
return value
|
||||
|
||||
|
||||
def string_value(value, label: str):
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ConfigError(f"{label} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def exact_keys(value: dict, expected: set[str], label: str):
|
||||
actual = set(value)
|
||||
if actual != expected:
|
||||
missing = sorted(expected - actual)
|
||||
extra = sorted(actual - expected)
|
||||
raise ConfigError(f"{label} keys differ (missing={missing}, extra={extra})")
|
||||
|
||||
|
||||
def schema_v1(value: dict, label: str):
|
||||
schema = object_value(value.get("schema"), f"{label}.schema")
|
||||
exact_keys(schema, {"version"}, f"{label}.schema")
|
||||
if schema["version"] != 1:
|
||||
raise ConfigError(f"{label}.schema.version must be 1")
|
||||
|
||||
|
||||
def compact_json(value) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
@@ -1,108 +0,0 @@
|
||||
import { DIGEST_PATTERN, type Digest } from "./json.ts";
|
||||
|
||||
type Ensure = Readonly<{
|
||||
array(value: unknown, label: string): unknown[];
|
||||
digest(value: unknown, label: string): Digest;
|
||||
exactKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void;
|
||||
integer(value: unknown, label: string): number;
|
||||
never(message: string): never;
|
||||
record(value: unknown, label: string): Record<string, unknown>;
|
||||
that(condition: unknown, message: string): asserts condition;
|
||||
text(value: unknown, label: string): string;
|
||||
}>;
|
||||
|
||||
export const ensure: Ensure = Object.freeze({
|
||||
array(value, label) {
|
||||
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
||||
return value;
|
||||
},
|
||||
digest(value, label) {
|
||||
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) throw new Error(`${label} must be a sha256 digest`);
|
||||
return value as Digest;
|
||||
},
|
||||
exactKeys(value, allowed, label) {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (unknown.length > 0) throw new Error(`${label} contains unknown fields: ${unknown.join(", ")}`);
|
||||
},
|
||||
integer(value, label) {
|
||||
if (!Number.isSafeInteger(value) || Number(value) < 1) throw new Error(`${label} must be a positive integer`);
|
||||
return Number(value);
|
||||
},
|
||||
never(message) {
|
||||
throw new Error(message);
|
||||
},
|
||||
record(value, label) {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
},
|
||||
that(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
},
|
||||
text(value, label) {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
export const fail = ensure.never;
|
||||
|
||||
export function switchBy<Key extends string, Result>(
|
||||
key: Key,
|
||||
branches: Readonly<Record<Key, () => Result>>,
|
||||
): Result {
|
||||
const branch = branches[key];
|
||||
ensure.that(branch != null, `unsupported switch value: ${key}`);
|
||||
return branch();
|
||||
}
|
||||
|
||||
export class Options {
|
||||
readonly #values: ReadonlyMap<string, string>;
|
||||
|
||||
constructor(values: Readonly<Record<string, string>>) {
|
||||
this.#values = new Map(Object.entries(values));
|
||||
}
|
||||
|
||||
get(name: string): string {
|
||||
return this.#values.get(name) ?? ensure.never(`--${name} is required`);
|
||||
}
|
||||
|
||||
optional(name: string): string | undefined {
|
||||
return this.#values.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
type Command = Readonly<{
|
||||
options: readonly string[];
|
||||
run: (args: Options) => Promise<void> | void;
|
||||
}>;
|
||||
|
||||
export function command(options: readonly string[], run: Command["run"]): Command {
|
||||
const sorted = [...options].sort();
|
||||
ensure.that(new Set(sorted).size === sorted.length, "command options must not contain duplicates");
|
||||
return Object.freeze({ options: Object.freeze(sorted), run });
|
||||
}
|
||||
|
||||
export function commands<const Table extends Readonly<Record<string, Command>>>(
|
||||
table: Table,
|
||||
usage: () => string,
|
||||
) {
|
||||
return Object.freeze({
|
||||
async run(argv: readonly string[]): Promise<void> {
|
||||
const [name, ...args] = argv;
|
||||
ensure.that(name != null && Object.hasOwn(table, name), `unknown command: ${name ?? ""}\n${usage()}`);
|
||||
const selected = table[name as keyof Table]!;
|
||||
ensure.that(args.length % 2 === 0, `${name} options must be --name value pairs`);
|
||||
const values: Record<string, string> = {};
|
||||
for (let index = 0; index < args.length; index += 2) {
|
||||
const rawKey = args[index]!;
|
||||
const key = rawKey.replace(/^--/u, "");
|
||||
ensure.that(rawKey === `--${key}` && selected.options.includes(key), `${name} does not support ${rawKey}`);
|
||||
ensure.that(values[key] == null, `${name} received duplicate option ${rawKey}`);
|
||||
const value = args[index + 1];
|
||||
ensure.that(value != null && !value.startsWith("--"), `${rawKey} requires a value`);
|
||||
values[key] = value;
|
||||
}
|
||||
await selected.run(new Options(values));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { ensure, fail } from "./control.ts";
|
||||
import { canonicalJson, digest, type Digest, type Json } from "./json.ts";
|
||||
|
||||
export type GitHashPath = Readonly<{
|
||||
excludeDirectoryNames?: readonly string[];
|
||||
excludePaths?: readonly string[];
|
||||
normalizePackageVersion?: boolean;
|
||||
normalizeTextLineEndings?: boolean;
|
||||
path: string;
|
||||
}>;
|
||||
|
||||
type GitEntry = Readonly<{ mode: string; object: string; path: string; type: string }>;
|
||||
|
||||
export function git(args: readonly string[], cwd: string): Buffer {
|
||||
try {
|
||||
return execFileSync("git", args, { cwd, encoding: "buffer", maxBuffer: 256 * 1024 * 1024 });
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
fail(`git ${args.join(" ")} failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function listGitEntries(root: string, ref: string, source: GitHashPath): readonly GitEntry[] {
|
||||
const raw = git(["ls-tree", "-rz", "--full-tree", ref, "--", source.path], root);
|
||||
const entries = raw.toString("utf8").split("\0").filter(Boolean).map((line): GitEntry => {
|
||||
const tab = line.indexOf("\t");
|
||||
if (tab < 0) fail(`invalid git ls-tree entry: ${line}`);
|
||||
const [mode, type, object] = line.slice(0, tab).split(" ");
|
||||
if (mode == null || type == null || object == null) fail(`invalid git ls-tree metadata: ${line}`);
|
||||
return { mode, object, path: line.slice(tab + 1), type };
|
||||
});
|
||||
const excludedDirectories = new Set(source.excludeDirectoryNames ?? []);
|
||||
const exclusions = (source.excludePaths ?? []).map((path) => `${source.path}/${path}`);
|
||||
return entries.filter((entry) => {
|
||||
const relativePath = entry.path === source.path ? "" : entry.path.slice(source.path.length + 1);
|
||||
if (relativePath.split("/").some((segment) => excludedDirectories.has(segment))) return false;
|
||||
return !exclusions.some((excluded) => entry.path === excluded || entry.path.startsWith(`${excluded}/`));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizedBlob(root: string, entry: GitEntry, source: GitHashPath): Readonly<{ digest: Digest; size: number }> {
|
||||
let body = git(["cat-file", "blob", entry.object], root);
|
||||
if (source.normalizePackageVersion === true && (entry.path === "package.json" || entry.path.endsWith("/package.json"))) {
|
||||
try {
|
||||
const packageJson = ensure.record(JSON.parse(body.toString("utf8")), `${entry.path} package metadata`);
|
||||
delete packageJson.version;
|
||||
body = Buffer.from(`${canonicalJson(packageJson)}\n`);
|
||||
} catch {
|
||||
// The owning validation reports malformed JSON; identity remains byte-exact.
|
||||
}
|
||||
}
|
||||
if (source.normalizeTextLineEndings === true && !body.includes(0)) {
|
||||
body = Buffer.from(body.toString("utf8").replace(/\r\n?/gu, "\n"));
|
||||
}
|
||||
return { digest: digest(body), size: body.byteLength };
|
||||
}
|
||||
|
||||
export function gitPathSetDigest(
|
||||
root: string,
|
||||
ref: string,
|
||||
sources: readonly GitHashPath[],
|
||||
options: Readonly<{ domain: string; label: string }>,
|
||||
): Digest {
|
||||
const seen = new Set<string>();
|
||||
const entries: Json[] = [];
|
||||
for (const source of sources) {
|
||||
const matched = listGitEntries(root, ref, source);
|
||||
if (matched.length === 0) fail(`${options.label} path does not exist at ${ref}: ${source.path}`);
|
||||
for (const entry of matched) {
|
||||
if (seen.has(entry.path)) fail(`${options.label} paths overlap at ${entry.path}`);
|
||||
seen.add(entry.path);
|
||||
if (entry.type === "blob") {
|
||||
const normalized = normalizedBlob(root, entry, source);
|
||||
entries.push({ kind: entry.mode === "120000" ? "symlink" : "file", mode: entry.mode, path: entry.path, ...normalized });
|
||||
} else {
|
||||
entries.push({ kind: entry.type, mode: entry.mode, object: entry.object, path: entry.path });
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort((left, right) => String((left as Record<string, Json>).path).localeCompare(String((right as Record<string, Json>).path)));
|
||||
return digest(canonicalJson({ domain: options.domain, entries }));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def append_outputs(values: dict[str, str]):
|
||||
output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
lines = [f"{key}={value}" for key, value in values.items()]
|
||||
if output_path:
|
||||
with Path(output_path).open("a", encoding="utf-8") as output:
|
||||
output.write("\n".join(lines) + "\n")
|
||||
else:
|
||||
print("\n".join(lines))
|
||||
|
||||
|
||||
def append_summary(markdown: str):
|
||||
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if summary_path:
|
||||
with Path(summary_path).open("a", encoding="utf-8") as summary:
|
||||
summary.write(markdown.rstrip() + "\n")
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
export type Digest = `sha256:${string}`;
|
||||
|
||||
export const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
||||
|
||||
export function canonicalize(value: unknown): Json {
|
||||
if (value === null) return null;
|
||||
if (typeof value === "boolean" || typeof value === "string") return value;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new Error("canonical JSON cannot contain non-finite numbers");
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (value == null || typeof value !== "object") throw new Error("canonical JSON value must be an object");
|
||||
return Object.fromEntries(Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => [key, canonicalize(entry)]));
|
||||
}
|
||||
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(canonicalize(value));
|
||||
}
|
||||
|
||||
export function digest(value: string | Uint8Array): Digest {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ def annotation_indicates_ordinary_failure(item: dict[str, Any]) -> bool:
|
||||
|
||||
Notice- and warning-level annotations without markers are setup / policy
|
||||
noise and must not reclassify a pure runner-shutdown job as ordinary.
|
||||
Concrete repository path: ``scripts/scopes.ts`` emits ``::warning::`` when
|
||||
Concrete repository path: ``.github/scripts/scopes.py`` emits ``::warning::`` when
|
||||
changed-file resolution falls back to the full plan; that warning must not
|
||||
suppress infra-cancel retry when a later shutdown marker is also present.
|
||||
|
||||
|
||||
+91
-73
@@ -1,90 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from lib.config import (
|
||||
ConfigError,
|
||||
array_value,
|
||||
compact_json,
|
||||
exact_keys,
|
||||
load_json,
|
||||
object_value,
|
||||
repository_root,
|
||||
schema_v1,
|
||||
string_value,
|
||||
)
|
||||
from lib.github import append_outputs
|
||||
|
||||
|
||||
GITHUB_HOSTED = ["ubuntu-24.04"]
|
||||
WINDOWS_HOSTED = ["windows-latest"]
|
||||
BLACKSMITH_4VCPU = ["blacksmith-4vcpu-ubuntu-2404"]
|
||||
BLACKSMITH_8VCPU = ["blacksmith-8vcpu-ubuntu-2404"]
|
||||
NEXU_SMALL = ["nexu-runners-small"]
|
||||
NEXU_MEDIUM = ["nexu-runners-medium"]
|
||||
NEXU_LARGE = ["nexu-runners-large"]
|
||||
NEXU_XLARGE = ["nexu-runners-xlarge"]
|
||||
EXPECTED_CLASSES = {
|
||||
"control",
|
||||
"general_medium",
|
||||
"workspace_unit",
|
||||
"windows_tools",
|
||||
"js_hot",
|
||||
"ui_hot",
|
||||
"ui_p0",
|
||||
"ui_p0_heavy",
|
||||
"visual_hot",
|
||||
}
|
||||
|
||||
|
||||
def compact_json(value):
|
||||
return json.dumps(value, separators=(",", ":"))
|
||||
def load_contract(path: Path):
|
||||
config = object_value(load_json(path), "runners")
|
||||
exact_keys(
|
||||
config,
|
||||
{"schema", "defaultMode", "aliases", "profiles", "modes"},
|
||||
"runners",
|
||||
)
|
||||
schema_v1(config, "runners")
|
||||
default_mode = string_value(config["defaultMode"], "runners.defaultMode")
|
||||
aliases = object_value(config["aliases"], "runners.aliases")
|
||||
profiles = object_value(config["profiles"], "runners.profiles")
|
||||
modes = object_value(config["modes"], "runners.modes")
|
||||
|
||||
normalized_profiles = {}
|
||||
for name, labels in profiles.items():
|
||||
string_value(name, "runners.profiles key")
|
||||
labels = array_value(labels, f"runners.profiles.{name}")
|
||||
if not labels:
|
||||
raise ConfigError(f"runners.profiles.{name} must not be empty")
|
||||
normalized_profiles[name] = [
|
||||
string_value(label, f"runners.profiles.{name}[]") for label in labels
|
||||
]
|
||||
|
||||
normalized_modes = {}
|
||||
for name, assignments in modes.items():
|
||||
string_value(name, "runners.modes key")
|
||||
assignments = object_value(assignments, f"runners.modes.{name}")
|
||||
exact_keys(assignments, EXPECTED_CLASSES, f"runners.modes.{name}")
|
||||
normalized_modes[name] = {}
|
||||
for runner_class, profile in assignments.items():
|
||||
profile = string_value(profile, f"runners.modes.{name}.{runner_class}")
|
||||
if profile not in normalized_profiles:
|
||||
raise ConfigError(
|
||||
f"runners.modes.{name}.{runner_class} references unknown profile {profile}"
|
||||
)
|
||||
normalized_modes[name][runner_class] = normalized_profiles[profile]
|
||||
|
||||
if default_mode not in normalized_modes:
|
||||
raise ConfigError(f"runners.defaultMode references unknown mode {default_mode}")
|
||||
for alias, target in aliases.items():
|
||||
string_value(alias, "runners.aliases key")
|
||||
target = string_value(target, f"runners.aliases.{alias}")
|
||||
if target not in normalized_modes:
|
||||
raise ConfigError(f"runners.aliases.{alias} references unknown mode {target}")
|
||||
|
||||
return default_mode, aliases, normalized_modes
|
||||
|
||||
|
||||
def normalize_mode(raw_mode):
|
||||
mode = raw_mode or "default"
|
||||
if mode in {"default", "performance", "economic", "blacksmith"}:
|
||||
return mode
|
||||
return "default"
|
||||
|
||||
|
||||
def resolve_contract(mode):
|
||||
if mode == "economic":
|
||||
control = GITHUB_HOSTED
|
||||
workload = GITHUB_HOSTED
|
||||
browser_workload = GITHUB_HOSTED
|
||||
ui_p0_workload = GITHUB_HOSTED
|
||||
ui_p0_heavy_workload = GITHUB_HOSTED
|
||||
elif mode == "blacksmith":
|
||||
control = BLACKSMITH_4VCPU
|
||||
workload = BLACKSMITH_4VCPU
|
||||
browser_workload = BLACKSMITH_8VCPU
|
||||
# Blacksmith has no xlarge tier; keep both UI P0 classes on 8vcpu.
|
||||
ui_p0_workload = BLACKSMITH_8VCPU
|
||||
ui_p0_heavy_workload = BLACKSMITH_8VCPU
|
||||
else:
|
||||
control = NEXU_SMALL
|
||||
workload = NEXU_MEDIUM
|
||||
browser_workload = NEXU_LARGE
|
||||
# Measured UI P0 shards fit medium (mem well under 14Gi lim). Keep a
|
||||
# dedicated heavy class for multi-client collab, which has hit large/xlarge
|
||||
# memory ceilings and is not covered by the medium right-size evidence.
|
||||
ui_p0_workload = NEXU_MEDIUM
|
||||
ui_p0_heavy_workload = NEXU_XLARGE
|
||||
|
||||
def resolve(path: Path, requested_mode: Optional[str]):
|
||||
default_mode, aliases, modes = load_contract(path)
|
||||
selected = requested_mode or default_mode
|
||||
selected = aliases.get(selected, selected)
|
||||
if selected not in modes:
|
||||
raise ConfigError(f"unknown runner mode: {selected}")
|
||||
return {
|
||||
"runs_on": {
|
||||
"control": control,
|
||||
"general_medium": workload,
|
||||
"workspace_unit": workload,
|
||||
"windows_tools": WINDOWS_HOSTED,
|
||||
"js_hot": workload,
|
||||
"ui_hot": browser_workload,
|
||||
"ui_p0": ui_p0_workload,
|
||||
"ui_p0_heavy": ui_p0_heavy_workload,
|
||||
"visual_hot": browser_workload,
|
||||
},
|
||||
"decision": {
|
||||
"schema_version": 1,
|
||||
"mode": mode,
|
||||
},
|
||||
"runs_on": modes[selected],
|
||||
"decision": {"schema_version": 1, "mode": selected},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
contract = resolve_contract(normalize_mode(os.environ.get("OD_CI_RUNNER_MODE")))
|
||||
output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
lines = [
|
||||
f"{key}={value if isinstance(value, str) else compact_json(value)}"
|
||||
for key, value in contract.items()
|
||||
]
|
||||
|
||||
if output_path:
|
||||
with Path(output_path).open("a", encoding="utf-8") as output:
|
||||
for line in lines:
|
||||
output.write(f"{line}\n")
|
||||
else:
|
||||
for line in lines:
|
||||
print(line)
|
||||
def main() -> int:
|
||||
root = repository_root(__file__)
|
||||
config_path = root / ".github/config/runners.json"
|
||||
try:
|
||||
contract = resolve(config_path, os.environ.get("OD_CI_RUNNER_MODE"))
|
||||
except ConfigError as error:
|
||||
print(f"runner configuration error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
append_outputs({key: compact_json(value) for key, value in contract.items()})
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from lib.config import ConfigError, compact_json, load_json, object_value, repository_root, schema_v1
|
||||
from lib.github import append_outputs, append_summary
|
||||
|
||||
|
||||
CONFIDENCE = {"medium": 0, "certain": 1}
|
||||
WORKLOADS = {
|
||||
"static_gate",
|
||||
"preflight",
|
||||
"workspace_unit_tests",
|
||||
"daemon_unit_tests",
|
||||
"windows_tools_pack_payload_tests",
|
||||
"web_workspace_tests",
|
||||
"e2e_vitest",
|
||||
"playwright_critical",
|
||||
"ui_p0",
|
||||
"playwright_visual",
|
||||
}
|
||||
|
||||
|
||||
class ScopeContract:
|
||||
def __init__(self, path: Path):
|
||||
value = object_value(load_json(path), "scopes")
|
||||
expected = {"schema", "effects", "matches", "rules", "matrices", "uiP0Shadow"}
|
||||
if set(value) != expected:
|
||||
raise ConfigError(f"scopes keys must be {sorted(expected)}")
|
||||
schema_v1(value, "scopes")
|
||||
self.effects = self._string_list(value["effects"], "scopes.effects")
|
||||
if len(set(self.effects)) != len(self.effects):
|
||||
raise ConfigError("scopes.effects contains duplicates")
|
||||
self.matches = object_value(value["matches"], "scopes.matches")
|
||||
self.rules = value["rules"]
|
||||
self.matrices = object_value(value["matrices"], "scopes.matrices")
|
||||
self.shadow = object_value(value["uiP0Shadow"], "scopes.uiP0Shadow")
|
||||
self._validate()
|
||||
|
||||
@staticmethod
|
||||
def _string_list(value, label):
|
||||
if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
|
||||
raise ConfigError(f"{label} must be an array of non-empty strings")
|
||||
return value
|
||||
|
||||
def _validate_match(self, match, label, stack=()):
|
||||
match = object_value(match, label)
|
||||
allowed = {"prefixes", "exact", "regexes", "include", "exclude"}
|
||||
if not set(match) <= allowed:
|
||||
raise ConfigError(f"{label} has unsupported keys {sorted(set(match) - allowed)}")
|
||||
for field in ("prefixes", "exact", "regexes", "include", "exclude"):
|
||||
if field in match:
|
||||
values = self._string_list(match[field], f"{label}.{field}")
|
||||
for token in values:
|
||||
if field in {"include", "exclude"}:
|
||||
self._validate_token(token, f"{label}.{field}", stack)
|
||||
elif field == "regexes":
|
||||
try:
|
||||
re.compile(token)
|
||||
except re.error as error:
|
||||
raise ConfigError(f"invalid regex {token!r} in {label}: {error}") from error
|
||||
|
||||
def _validate_token(self, token, label, stack):
|
||||
if token.startswith("match://"):
|
||||
name = token.removeprefix("match://")
|
||||
if name not in self.matches:
|
||||
raise ConfigError(f"{label} references unknown match {name}")
|
||||
if name in stack:
|
||||
raise ConfigError(f"match cycle: {' -> '.join((*stack, name))}")
|
||||
self._validate_match(self.matches[name], f"scopes.matches.{name}", (*stack, name))
|
||||
elif not token.startswith("prefix://"):
|
||||
raise ConfigError(f"{label} has unsupported token {token}")
|
||||
|
||||
def _validate(self):
|
||||
for name, match in self.matches.items():
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ConfigError("scopes.matches keys must be non-empty strings")
|
||||
self._validate_match(match, f"scopes.matches.{name}", (name,))
|
||||
if not isinstance(self.rules, list) or not self.rules:
|
||||
raise ConfigError("scopes.rules must be a non-empty array")
|
||||
seen = set()
|
||||
for index, rule in enumerate(self.rules):
|
||||
rule = object_value(rule, f"scopes.rules[{index}]")
|
||||
allowed = {"id", "match", "effects", "confidence"}
|
||||
if not set(rule) <= allowed or not {"id", "match", "effects", "confidence"} <= set(rule):
|
||||
raise ConfigError(f"scopes.rules[{index}] has invalid keys")
|
||||
rule_id = rule["id"]
|
||||
if not isinstance(rule_id, str) or not rule_id or rule_id in seen:
|
||||
raise ConfigError(f"invalid or duplicate scope rule id {rule_id!r}")
|
||||
seen.add(rule_id)
|
||||
self._validate_match(rule["match"], f"scopes.rules.{rule_id}.match")
|
||||
effects = self._string_list(rule["effects"], f"scopes.rules.{rule_id}.effects")
|
||||
unknown = set(effects) - set(self.effects)
|
||||
if unknown:
|
||||
raise ConfigError(f"scope rule {rule_id} has unknown effects {sorted(unknown)}")
|
||||
if rule["confidence"] not in CONFIDENCE:
|
||||
raise ConfigError(f"scope rule {rule_id} has invalid confidence")
|
||||
if set(self.matrices) != {"ui_p0", "visual"}:
|
||||
raise ConfigError("scopes.matrices must contain ui_p0 and visual")
|
||||
matrix_names = {}
|
||||
for matrix_name, fields in (("ui_p0", {"name", "shard"}), ("visual", {"name", "files"})):
|
||||
entries = self.matrices[matrix_name]
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise ConfigError(f"scopes.matrices.{matrix_name} must be a non-empty array")
|
||||
names = []
|
||||
for index, entry in enumerate(entries):
|
||||
entry = object_value(entry, f"scopes.matrices.{matrix_name}[{index}]")
|
||||
if set(entry) != fields:
|
||||
raise ConfigError(f"scopes.matrices.{matrix_name}[{index}] keys must be {sorted(fields)}")
|
||||
for field in fields:
|
||||
if not isinstance(entry[field], str) or not entry[field]:
|
||||
raise ConfigError(f"scopes.matrices.{matrix_name}[{index}].{field} must be a non-empty string")
|
||||
names.append(entry["name"])
|
||||
if len(set(names)) != len(names):
|
||||
raise ConfigError(f"scopes.matrices.{matrix_name} contains duplicate names")
|
||||
matrix_names[matrix_name] = set(names)
|
||||
if set(self.shadow) != {"match", "matrixNames"}:
|
||||
raise ConfigError("scopes.uiP0Shadow keys must be match and matrixNames")
|
||||
shadow_match = self.shadow.get("match")
|
||||
if shadow_match not in self.matches:
|
||||
raise ConfigError("scopes.uiP0Shadow.match is unknown")
|
||||
shadow_names = self._string_list(self.shadow.get("matrixNames"), "scopes.uiP0Shadow.matrixNames")
|
||||
if len(set(shadow_names)) != len(shadow_names):
|
||||
raise ConfigError("scopes.uiP0Shadow.matrixNames contains duplicates")
|
||||
unknown_shadow_names = set(shadow_names) - matrix_names["ui_p0"]
|
||||
if unknown_shadow_names:
|
||||
raise ConfigError(f"scopes.uiP0Shadow.matrixNames contains unknown names {sorted(unknown_shadow_names)}")
|
||||
|
||||
def _token_matches(self, file, token):
|
||||
if token.startswith("match://"):
|
||||
return self.match(file, self.matches[token.removeprefix("match://")])
|
||||
return file.startswith(token.removeprefix("prefix://"))
|
||||
|
||||
def match(self, file, match):
|
||||
positives = []
|
||||
positives.extend(file.startswith(prefix) for prefix in match.get("prefixes", []))
|
||||
positives.extend(file == exact for exact in match.get("exact", []))
|
||||
positives.extend(re.search(pattern, file) is not None for pattern in match.get("regexes", []))
|
||||
positives.extend(self._token_matches(file, token) for token in match.get("include", []))
|
||||
if positives and not any(positives):
|
||||
return False
|
||||
if any(self._token_matches(file, token) for token in match.get("exclude", [])):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def empty_effects(contract):
|
||||
return {effect: False for effect in contract.effects}
|
||||
|
||||
|
||||
def evaluate(contract, files, threshold, derive_workspace):
|
||||
outputs = empty_effects(contract)
|
||||
decisions = []
|
||||
for file in files:
|
||||
matched = [rule for rule in contract.rules if contract.match(file, rule["match"])]
|
||||
matched_ids = [rule["id"] for rule in matched]
|
||||
if not matched:
|
||||
outputs = {effect: True for effect in contract.effects}
|
||||
decisions.append({"file": file, "matchedRules": [], "escalated": True, "reason": "unmatched"})
|
||||
continue
|
||||
if any(CONFIDENCE[rule["confidence"]] < CONFIDENCE[threshold] for rule in matched):
|
||||
outputs = {effect: True for effect in contract.effects}
|
||||
decisions.append({"file": file, "matchedRules": matched_ids, "escalated": True, "reason": "below-threshold"})
|
||||
continue
|
||||
for rule in matched:
|
||||
for effect in rule["effects"]:
|
||||
outputs[effect] = True
|
||||
decisions.append({"file": file, "matchedRules": matched_ids, "escalated": False})
|
||||
if derive_workspace and any(outputs[name] for name in (
|
||||
"daemon_tests_required", "web_tests_required", "tools_dev_tests_required", "tools_pack_tests_required"
|
||||
)):
|
||||
outputs["workspace_validation_required"] = True
|
||||
return outputs, decisions
|
||||
|
||||
|
||||
def enabled_workloads(outputs, ci_mode, full_lanes):
|
||||
any_scope = any(outputs.values())
|
||||
broad = full_lanes or ci_mode == "hot" or any_scope
|
||||
ui_p0 = full_lanes or outputs["ui_p0_validation_required"]
|
||||
enabled = {
|
||||
"static_gate": True,
|
||||
"preflight": True,
|
||||
"workspace_unit_tests": broad,
|
||||
"daemon_unit_tests": outputs["daemon_tests_required"],
|
||||
"windows_tools_pack_payload_tests": full_lanes or outputs["tools_pack_tests_required"],
|
||||
"web_workspace_tests": full_lanes or outputs["web_tests_required"],
|
||||
"e2e_vitest": full_lanes or outputs["web_tests_required"] or outputs["ui_p0_validation_required"],
|
||||
"playwright_critical": outputs["ui_critical_validation_required"] and not ui_p0,
|
||||
"ui_p0": ui_p0,
|
||||
"playwright_visual": full_lanes or outputs["visual_validation_required"],
|
||||
}
|
||||
if set(enabled) != WORKLOADS:
|
||||
raise AssertionError("scope workload map drifted")
|
||||
return enabled, broad
|
||||
|
||||
|
||||
def build_plan(contract, files, source, threshold, ci_mode, full_lanes, derive_workspace, resolved=True):
|
||||
if threshold is None:
|
||||
outputs = {effect: True for effect in contract.effects}
|
||||
decisions = []
|
||||
else:
|
||||
outputs, decisions = evaluate(contract, files, threshold, derive_workspace)
|
||||
enabled, broad = enabled_workloads(outputs, ci_mode, full_lanes)
|
||||
hits = Counter(rule for decision in decisions for rule in decision["matchedRules"])
|
||||
shadow_match = contract.matches[contract.shadow["match"]]
|
||||
candidate = bool(files) and resolved and all(contract.match(file, shadow_match) for file in files)
|
||||
shadow_names = set(contract.shadow["matrixNames"])
|
||||
shadow_matrix = [entry for entry in contract.matrices["ui_p0"] if entry["name"] in shadow_names] if candidate else contract.matrices["ui_p0"]
|
||||
scopes = {
|
||||
**outputs,
|
||||
"ci_mode": ci_mode,
|
||||
"run_preflight_typecheck": broad,
|
||||
}
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"source": source,
|
||||
"scopes": scopes,
|
||||
"enabled": enabled,
|
||||
"matrices": contract.matrices,
|
||||
"trace": {
|
||||
"threshold": threshold or "none",
|
||||
"filesResolved": resolved,
|
||||
"fileCount": len(files) if resolved else 0,
|
||||
"ruleHits": dict(hits),
|
||||
"escalations": [
|
||||
{"file": item["file"], "reason": item["reason"]}
|
||||
for item in decisions if item["escalated"]
|
||||
],
|
||||
"uiP0Shadow": {
|
||||
"mode": "candidate" if candidate else "full-fallback",
|
||||
"matrix": shadow_matrix,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def required_env(name):
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise ConfigError(f"{name} is required")
|
||||
return value
|
||||
|
||||
|
||||
def event_payload():
|
||||
return load_json(Path(required_env("GITHUB_EVENT_PATH")))
|
||||
|
||||
|
||||
def run_gh(args):
|
||||
override = os.environ.get("OPEN_DESIGN_GH_NODE_SCRIPT")
|
||||
command = (["node", override] if override else ["gh"]) + args
|
||||
return subprocess.run(command, check=True, text=True, stdout=subprocess.PIPE).stdout
|
||||
|
||||
|
||||
def changed_files_for_environment():
|
||||
event_name = required_env("GITHUB_EVENT_NAME")
|
||||
repository = required_env("GITHUB_REPOSITORY")
|
||||
event = event_payload()
|
||||
if event_name == "pull_request":
|
||||
number = event.get("pull_request", {}).get("number")
|
||||
if number is None:
|
||||
raise ConfigError("pull_request.number is required")
|
||||
output = run_gh(["api", "--paginate", f"repos/{repository}/pulls/{number}/files", "--jq", ".[] | .filename, (.previous_filename // empty)"])
|
||||
return event_name, output.splitlines(), "medium", "hot", False, True, True
|
||||
if event_name == "workflow_dispatch":
|
||||
mode = event.get("inputs", {}).get("ci_mode", "full")
|
||||
if mode not in {"hot", "full"}:
|
||||
raise ConfigError(f"unsupported workflow_dispatch ci_mode: {mode}")
|
||||
if mode == "hot":
|
||||
sha = required_env("GITHUB_SHA")
|
||||
output = run_gh(["api", "--paginate", f"repos/{repository}/compare/main...{sha}", "--jq", "(.files // [])[] | .filename, (.previous_filename // empty)"])
|
||||
return "workflow_dispatch:hot", output.splitlines(), "medium", "hot", False, False, True
|
||||
return event_name, [], None, "full", True, False, False
|
||||
if event_name == "merge_group":
|
||||
group = event.get("merge_group", {})
|
||||
base, head = group.get("base_sha"), group.get("head_sha")
|
||||
if not base or not head:
|
||||
raise ConfigError("merge_group base_sha and head_sha are required")
|
||||
try:
|
||||
comparison = json.loads(run_gh(["api", f"repos/{repository}/compare/{base}...{head}"]))
|
||||
records = comparison.get("files", [])
|
||||
if len(records) >= 300:
|
||||
raise ConfigError("merge_group comparison reached the 300-file ceiling")
|
||||
files = [name for record in records for name in (record.get("filename"), record.get("previous_filename")) if isinstance(name, str)]
|
||||
if not files:
|
||||
return "merge_group:empty-resolution", [], None, "full", True, True, False
|
||||
return event_name, files, "certain", "full", False, True, True
|
||||
except (subprocess.SubprocessError, json.JSONDecodeError, ConfigError) as error:
|
||||
print(f"::warning::merge_group resolution failed; using full plan: {error}", file=sys.stderr)
|
||||
return "merge_group:resolution-error", [], None, "full", True, True, False
|
||||
return event_name, [], None, "full", True, False, False
|
||||
|
||||
|
||||
def emit_plan(plan, output_path=None):
|
||||
if output_path:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(plan, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
append_outputs({
|
||||
"scopes": compact_json(plan["scopes"]),
|
||||
"ui_p0_matrix": compact_json(plan["matrices"]["ui_p0"]),
|
||||
"visual_matrix": compact_json(plan["matrices"]["visual"]),
|
||||
})
|
||||
trace = plan["trace"]
|
||||
lines = [
|
||||
"### Scope decision trace", "",
|
||||
f"- source: `{plan['source']}`, trust threshold: `{trace['threshold']}`",
|
||||
f"- files: {trace['fileCount'] if trace['filesResolved'] else 'not resolved'}, escalated: {len(trace['escalations'])}",
|
||||
f"- UI P0 shadow: `{trace['uiP0Shadow']['mode']}`",
|
||||
]
|
||||
if trace["ruleHits"]:
|
||||
lines += ["", "| Rule | Hits |", "| --- | ---: |"]
|
||||
lines += [f"| {name} | {count} |" for name, count in sorted(trace["ruleHits"].items(), key=lambda item: -item[1])]
|
||||
append_summary("\n".join(lines))
|
||||
print("scope decision trace:\n" + json.dumps(trace, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", type=Path)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
github = sub.add_parser("github-output")
|
||||
github.add_argument("--output", type=Path)
|
||||
plan = sub.add_parser("plan")
|
||||
plan.add_argument("--context", choices=("pr", "merge-queue", "full"), default="pr")
|
||||
plan.add_argument("--files", nargs="*", default=[])
|
||||
plan.add_argument("--files-from")
|
||||
sub.add_parser("validate")
|
||||
sub.add_parser("rules")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
root = repository_root(__file__)
|
||||
args = parse_args()
|
||||
contract = ScopeContract(args.config or root / ".github/config/scopes.json")
|
||||
if args.command == "validate":
|
||||
print("scope configuration is valid")
|
||||
return 0
|
||||
if args.command == "rules":
|
||||
for rule in contract.rules:
|
||||
print(f"{rule['id']}\n confidence: {rule['confidence']}\n effects: {', '.join(rule['effects']) or '(none)'}")
|
||||
return 0
|
||||
if args.command == "github-output":
|
||||
source, files, threshold, mode, full_lanes, derive, resolved = changed_files_for_environment()
|
||||
emit_plan(build_plan(contract, files, source, threshold, mode, full_lanes, derive, resolved), args.output)
|
||||
return 0
|
||||
files = list(args.files)
|
||||
if args.files_from:
|
||||
content = sys.stdin.read() if args.files_from == "-" else Path(args.files_from).read_text(encoding="utf-8")
|
||||
files += [line for line in content.splitlines() if line]
|
||||
if args.context == "full":
|
||||
settings = (None, "full", True, False, False)
|
||||
elif args.context == "merge-queue":
|
||||
settings = ("certain", "full", False, True, True)
|
||||
else:
|
||||
settings = ("medium", "hot", False, True, True)
|
||||
threshold, mode, full_lanes, derive, resolved = settings
|
||||
print(json.dumps(build_plan(contract, files, f"cli:{args.context}", threshold, mode, full_lanes, derive, resolved), indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except ConfigError as error:
|
||||
print(f"scope configuration error: {error}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
+89
-69
@@ -59,49 +59,60 @@ jobs:
|
||||
|| vars.OD_CI_RUNNER_MODE }}
|
||||
run: python3 .github/scripts/runners.py
|
||||
|
||||
scopes:
|
||||
name: Detect validation scopes
|
||||
plan:
|
||||
name: Plan validation workloads
|
||||
needs: [runners]
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }}
|
||||
outputs:
|
||||
ci_mode: ${{ steps.detect.outputs.ci_mode }}
|
||||
daemon_tests_required: ${{ steps.detect.outputs.daemon_tests_required }}
|
||||
web_tests_required: ${{ steps.detect.outputs.web_tests_required }}
|
||||
tools_dev_tests_required: ${{ steps.detect.outputs.tools_dev_tests_required }}
|
||||
tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }}
|
||||
ui_p0_validation_required: ${{ steps.detect.outputs.ui_p0_validation_required }}
|
||||
visual_validation_required: ${{ steps.detect.outputs.visual_validation_required }}
|
||||
workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }}
|
||||
run_e2e_vitest: ${{ steps.detect.outputs.run_e2e_vitest }}
|
||||
run_playwright_critical: ${{ steps.detect.outputs.run_playwright_critical }}
|
||||
run_playwright_visual: ${{ steps.detect.outputs.run_playwright_visual }}
|
||||
run_preflight: ${{ steps.detect.outputs.run_preflight }}
|
||||
run_preflight_typecheck: ${{ steps.detect.outputs.run_preflight_typecheck }}
|
||||
run_ui_p0: ${{ steps.detect.outputs.run_ui_p0 }}
|
||||
run_web_workspace_tests: ${{ steps.detect.outputs.run_web_workspace_tests }}
|
||||
run_windows_tools_pack_payload_tests: ${{ steps.detect.outputs.run_windows_tools_pack_payload_tests }}
|
||||
run_workspace_unit_tests: ${{ steps.detect.outputs.run_workspace_unit_tests }}
|
||||
ui_p0_matrix: ${{ steps.detect.outputs.ui_p0_matrix }}
|
||||
visual_matrix: ${{ steps.detect.outputs.visual_matrix }}
|
||||
run: ${{ steps.hash.outputs.run }}
|
||||
equal: ${{ steps.hash.outputs.equal }}
|
||||
scopes: ${{ steps.scopes.outputs.scopes }}
|
||||
ui_p0_matrix: ${{ steps.scopes.outputs.ui_p0_matrix }}
|
||||
visual_matrix: ${{ steps.scopes.outputs.visual_matrix }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Detect workspace and app test scopes
|
||||
id: detect
|
||||
- name: Resolve validation scopes
|
||||
id: scopes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node --experimental-strip-types scripts/scopes.ts github-output
|
||||
run: python3 .github/scripts/scopes.py github-output --output "$RUNNER_TEMP/scope-plan.json"
|
||||
|
||||
- name: Restore previous hash map
|
||||
id: hash_cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ runner.temp }}/hash-state.json
|
||||
key: ci-hash-${{ runner.os }}-${{ github.ref }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
ci-hash-${{ runner.os }}-${{ github.ref }}-
|
||||
|
||||
- name: Compare and replace hash map
|
||||
id: hash
|
||||
run: >-
|
||||
python3 .github/scripts/hash.py github-output
|
||||
--workflow ci
|
||||
--scope-plan "$RUNNER_TEMP/scope-plan.json"
|
||||
--state "$RUNNER_TEMP/hash-state.json"
|
||||
|
||||
# Carry the freshly computed comparison register to the gate without
|
||||
# publishing it as reusable cache state. A failed gate must leave the
|
||||
# previously successful map in place so an identical retry runs again.
|
||||
- name: Upload pending hash map
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ci-hash-state-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/hash-state.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Create visual report handoff
|
||||
id: visual_report_handoff
|
||||
if: ${{ github.event_name == 'pull_request' && steps.detect.outputs.run_playwright_visual == 'true' }}
|
||||
if: ${{ github.event_name == 'pull_request' && fromJSON(steps.hash.outputs.run).playwright_visual }}
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -145,7 +156,8 @@ jobs:
|
||||
|
||||
static_gate:
|
||||
name: Static gate
|
||||
needs: [runners]
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).static_gate }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).control }}
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
@@ -236,8 +248,8 @@ jobs:
|
||||
|
||||
preflight:
|
||||
name: Preflight
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.run_preflight == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).preflight }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).general_medium }}
|
||||
timeout-minutes: 45
|
||||
|
||||
@@ -269,14 +281,14 @@ jobs:
|
||||
# If postinstall grows a targeted app type-generation phase covering these
|
||||
# three exports without broad app builds, this CI prebuild can be removed.
|
||||
- name: Prebuild workspace type declarations
|
||||
if: ${{ needs.scopes.outputs.run_preflight_typecheck == 'true' }}
|
||||
if: ${{ fromJSON(needs.plan.outputs.scopes).run_preflight_typecheck }}
|
||||
run: |
|
||||
pnpm --filter @open-design/daemon build
|
||||
pnpm --filter @open-design/desktop build
|
||||
pnpm --filter @open-design/web build:sidecar
|
||||
|
||||
- name: Typecheck workspaces
|
||||
if: ${{ needs.scopes.outputs.run_preflight_typecheck == 'true' }}
|
||||
if: ${{ fromJSON(needs.plan.outputs.scopes).run_preflight_typecheck }}
|
||||
run: |
|
||||
pnpm -r --filter '!open-design' --filter '!@open-design/landing-page' --workspace-concurrency="${OPEN_DESIGN_WORKSPACE_CONCURRENCY:-1}" --if-present run typecheck
|
||||
pnpm exec tsc -p scripts/tsconfig.json --noEmit
|
||||
@@ -289,8 +301,8 @@ jobs:
|
||||
|
||||
workspace_unit_tests:
|
||||
name: Workspace unit tests
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.run_workspace_unit_tests == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).workspace_unit_tests }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).workspace_unit }}
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -313,15 +325,15 @@ jobs:
|
||||
pnpm --filter @open-design/platform test
|
||||
pnpm --filter @open-design/sidecar test
|
||||
pnpm --filter @open-design/sidecar-proto test
|
||||
if [ "${{ needs.scopes.outputs.tools_dev_tests_required }}" = "true" ]; then
|
||||
if [ "${{ fromJSON(needs.plan.outputs.scopes).tools_dev_tests_required }}" = "true" ]; then
|
||||
pnpm --filter @open-design/tools-dev test
|
||||
fi
|
||||
if [ "${{ needs.scopes.outputs.tools_pack_tests_required }}" = "true" ]; then
|
||||
if [ "${{ fromJSON(needs.plan.outputs.scopes).tools_pack_tests_required }}" = "true" ]; then
|
||||
pnpm --filter @open-design/desktop build
|
||||
pnpm --filter @open-design/desktop test
|
||||
pnpm --filter @open-design/packaged test
|
||||
pnpm --filter @open-design/tools-pack test
|
||||
if [ "${{ needs.scopes.outputs.run_e2e_vitest }}" != "true" ]; then
|
||||
if [ "${{ fromJSON(needs.plan.outputs.run).e2e_vitest }}" != "true" ]; then
|
||||
pnpm --filter @open-design/e2e test tests/packaged-launcher-update-loop.test.ts
|
||||
fi
|
||||
fi
|
||||
@@ -329,8 +341,8 @@ jobs:
|
||||
|
||||
daemon_unit_tests:
|
||||
name: Daemon tests (${{ matrix.shard }}/4)
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.daemon_tests_required == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).daemon_unit_tests }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).workspace_unit }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
@@ -357,8 +369,8 @@ jobs:
|
||||
|
||||
windows_tools_pack_payload_tests:
|
||||
name: Windows tools-pack payload tests
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.run_windows_tools_pack_payload_tests == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).windows_tools_pack_payload_tests }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).windows_tools }}
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -376,8 +388,8 @@ jobs:
|
||||
|
||||
web_workspace_tests:
|
||||
name: Web workspace tests (${{ matrix.shard }}/2)
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.run_web_workspace_tests == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).web_workspace_tests }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).js_hot }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -405,8 +417,8 @@ jobs:
|
||||
|
||||
e2e_vitest:
|
||||
name: E2E Vitest
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.run_e2e_vitest == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).e2e_vitest }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).js_hot }}
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -440,8 +452,8 @@ jobs:
|
||||
|
||||
playwright_critical:
|
||||
name: Playwright critical (${{ matrix.group }})
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.run_playwright_critical == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).playwright_critical }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).ui_hot }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
@@ -488,14 +500,14 @@ jobs:
|
||||
|
||||
ui_p0:
|
||||
name: UI P0 (${{ matrix.name }})
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.run_ui_p0 == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).ui_p0 }}
|
||||
runs-on: ${{ matrix.shard == 'project-collab' && fromJSON(needs.runners.outputs.runs_on).ui_p0_heavy || fromJSON(needs.runners.outputs.runs_on).ui_p0 }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.scopes.outputs.ui_p0_matrix) }}
|
||||
include: ${{ fromJSON(needs.plan.outputs.ui_p0_matrix) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -560,14 +572,14 @@ jobs:
|
||||
|
||||
playwright_visual:
|
||||
name: Playwright visual (${{ matrix.name }})
|
||||
needs: [scopes, runners]
|
||||
if: ${{ needs.scopes.outputs.run_playwright_visual == 'true' }}
|
||||
needs: [plan, runners]
|
||||
if: ${{ fromJSON(needs.plan.outputs.run).playwright_visual }}
|
||||
runs-on: ${{ fromJSON(needs.runners.outputs.runs_on).visual_hot }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.scopes.outputs.visual_matrix) }}
|
||||
include: ${{ fromJSON(needs.plan.outputs.visual_matrix) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -652,7 +664,7 @@ jobs:
|
||||
validate:
|
||||
name: Validate workspace
|
||||
needs:
|
||||
- scopes
|
||||
- plan
|
||||
- runners
|
||||
- static_gate
|
||||
- preflight
|
||||
@@ -698,19 +710,10 @@ jobs:
|
||||
|
||||
required_misses="$(echo "$NEEDS_JSON" | jq -r '
|
||||
. as $needs |
|
||||
($needs.scopes.outputs // {}) as $out |
|
||||
def when($condition; $jobs): if $condition then $jobs else [] end;
|
||||
(($needs.plan.outputs.run // "{}") | fromjson) as $run |
|
||||
(
|
||||
["scopes", "static_gate"]
|
||||
+ when($out.run_preflight == "true"; ["preflight"])
|
||||
+ when($out.run_workspace_unit_tests == "true"; ["workspace_unit_tests"])
|
||||
+ when($out.daemon_tests_required == "true"; ["daemon_unit_tests"])
|
||||
+ when($out.run_windows_tools_pack_payload_tests == "true"; ["windows_tools_pack_payload_tests"])
|
||||
+ when($out.run_web_workspace_tests == "true"; ["web_workspace_tests"])
|
||||
+ when($out.run_e2e_vitest == "true"; ["e2e_vitest"])
|
||||
+ when($out.run_playwright_critical == "true"; ["playwright_critical"])
|
||||
+ when($out.run_ui_p0 == "true"; ["ui_p0"])
|
||||
+ when($out.run_playwright_visual == "true"; ["playwright_visual"])
|
||||
["plan"]
|
||||
+ [$run | to_entries[] | select(.value) | .key]
|
||||
)[]
|
||||
| select(($needs[.].result // "missing") != "success")
|
||||
| "\(.)=\($needs[.].result // "missing")"
|
||||
@@ -892,6 +895,23 @@ jobs:
|
||||
name: ${{ steps.merge_blocking_label_gate.outputs.comment_name }}
|
||||
path: ${{ steps.merge_blocking_label_gate.outputs.comment_path }}
|
||||
|
||||
- name: Download pending hash map
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: ci-hash-state-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
# Publish only after every merge-gating step has passed. The cache still
|
||||
# contains only the identity-to-hash comparison register; successful gate
|
||||
# completion controls publication rather than becoming cache payload.
|
||||
- name: Save successful hash map
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ runner.temp }}/hash-state.json
|
||||
key: ci-hash-${{ runner.os }}-${{ github.ref }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
runtime_summary:
|
||||
name: Runtime summary
|
||||
needs:
|
||||
|
||||
@@ -88,7 +88,7 @@ const releaseStableNotesScriptPath = join(workspaceRoot, ".github", "scripts", "
|
||||
const releaseStableScriptPath = join(workspaceRoot, "tools", "release", "src", "metadata", "prepare-stable.ts");
|
||||
const releaseBetaScriptPath = join(workspaceRoot, "tools", "release", "src", "metadata", "prepare-beta.ts");
|
||||
const packagedPackageJsonPath = join(workspaceRoot, "apps", "packaged", "package.json");
|
||||
const scopesScriptPath = join(workspaceRoot, "scripts", "scopes.ts");
|
||||
const scopesScriptPath = join(workspaceRoot, ".github", "scripts", "scopes.py");
|
||||
const runnersScriptPath = join(workspaceRoot, ".github", "scripts", "runners.py");
|
||||
const notifyDailyFeishuWorkflowPath = join(workspaceRoot, ".github", "workflows", "notify-daily-feishu.yml");
|
||||
const notifyReleaseFeishuWorkflowPath = join(workspaceRoot, ".github", "workflows", "notify-release-feishu.yml");
|
||||
@@ -253,39 +253,33 @@ async function readPackagedVersion(): Promise<string> {
|
||||
}
|
||||
|
||||
async function runScopesPrint(eventName: string, eventPayload: unknown, changedFiles: string[] = []): Promise<Record<string, unknown>> {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "od-scopes-"));
|
||||
const eventPath = join(tempDir, "event.json");
|
||||
const ghPath = join(tempDir, "gh");
|
||||
const ghCmdPath = join(tempDir, "gh.cmd");
|
||||
await writeFile(eventPath, JSON.stringify(eventPayload));
|
||||
const script = `#!/usr/bin/env node
|
||||
const changedFiles = ${JSON.stringify(changedFiles)};
|
||||
if (process.argv.includes("--jq")) {
|
||||
process.stdout.write(changedFiles.join("\\n"));
|
||||
if (changedFiles.length > 0) process.stdout.write("\\n");
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({ files: changedFiles.map((filename) => ({ filename })) }));
|
||||
}
|
||||
`;
|
||||
await writeFile(ghPath, script);
|
||||
await chmod(ghPath, 0o755);
|
||||
await writeFile(ghCmdPath, `@echo off\r\n"${process.execPath}" "${ghPath}" %*\r\n`);
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(process.execPath, ["--experimental-strip-types", scopesScriptPath, "print"], {
|
||||
cwd: workspaceRoot,
|
||||
env: workflowFixtureEnv({
|
||||
GITHUB_EVENT_NAME: eventName,
|
||||
GITHUB_EVENT_PATH: eventPath,
|
||||
GITHUB_REPOSITORY: "nexu-io/open-design",
|
||||
GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567",
|
||||
OPEN_DESIGN_GH_NODE_SCRIPT: ghPath,
|
||||
}, tempDir),
|
||||
});
|
||||
return JSON.parse(stdout) as Record<string, unknown>;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const inputMode = (eventPayload as { inputs?: { ci_mode?: string } }).inputs?.ci_mode;
|
||||
const context =
|
||||
eventName === "workflow_dispatch" && inputMode !== "hot"
|
||||
? "full"
|
||||
: eventName === "merge_group" && changedFiles.length === 0
|
||||
? "full"
|
||||
: eventName === "merge_group"
|
||||
? "merge-queue"
|
||||
: "pr";
|
||||
const { stdout } = await execFileAsync("python3", [scopesScriptPath, "plan", "--context", context, "--files", ...changedFiles], {
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
const value = JSON.parse(stdout) as {
|
||||
scopes: Record<string, unknown>;
|
||||
enabled: Record<string, boolean>;
|
||||
};
|
||||
return {
|
||||
...value.scopes,
|
||||
run_e2e_vitest: value.enabled.e2e_vitest,
|
||||
run_playwright_critical: value.enabled.playwright_critical,
|
||||
run_playwright_visual: value.enabled.playwright_visual,
|
||||
run_preflight: value.enabled.preflight,
|
||||
run_ui_p0: value.enabled.ui_p0,
|
||||
run_web_workspace_tests: value.enabled.web_workspace_tests,
|
||||
run_windows_tools_pack_payload_tests: value.enabled.windows_tools_pack_payload_tests,
|
||||
run_workspace_unit_tests: value.enabled.workspace_unit_tests,
|
||||
};
|
||||
}
|
||||
|
||||
async function runRunners(mode?: string): Promise<Record<string, string>> {
|
||||
@@ -407,7 +401,6 @@ describe("packaged smoke workflow", () => {
|
||||
expect(workflow).not.toContain("Smoke PR windows packaged runtime");
|
||||
expect(workflow).not.toContain("Smoke PR linux headless packaged runtime");
|
||||
expect(workflow).not.toContain("OD_PACKAGED_E2E_");
|
||||
expect(workflow).not.toContain("actions/cache/save");
|
||||
});
|
||||
|
||||
it("[P2] runs Windows launcher payload archive validation when tools-pack is touched", async () => {
|
||||
@@ -417,7 +410,7 @@ describe("packaged smoke workflow", () => {
|
||||
|
||||
expect(job).toContain("fromJSON(needs.runners.outputs.runs_on).windows_tools");
|
||||
expect(job).toContain("toJSON(fromJSON(needs.runners.outputs.runs_on).windows_tools)");
|
||||
expect(job).toContain("needs.scopes.outputs.run_windows_tools_pack_payload_tests == 'true'");
|
||||
expect(job).toContain("fromJSON(needs.plan.outputs.run).windows_tools_pack_payload_tests");
|
||||
expect(job).toContain("pnpm --filter @open-design/tools-pack exec vitest run tests/launcher-payload.test.ts");
|
||||
expect(validate).toContain("windows_tools_pack_payload_tests");
|
||||
});
|
||||
@@ -1075,15 +1068,14 @@ process.stdin.on("end", () => {
|
||||
|
||||
it("[P2] keeps PR and merge queue CI separated by hot/full validation mode", async () => {
|
||||
const workflow = await readFile(ciWorkflowPath, "utf8");
|
||||
const scopes = sectionBetween(workflow, " scopes:", " static_gate:");
|
||||
const plan = sectionBetween(workflow, " plan:", " static_gate:");
|
||||
const validate = sectionBetween(workflow, " validate:", " runtime_summary:");
|
||||
|
||||
expect(workflow).toContain("ci_mode:");
|
||||
expect(scopes).toContain("ci_mode: ${{ steps.detect.outputs.ci_mode }}");
|
||||
expect(scopes).toContain("ui_p0_validation_required: ${{ steps.detect.outputs.ui_p0_validation_required }}");
|
||||
expect(scopes).toContain("run_ui_p0: ${{ steps.detect.outputs.run_ui_p0 }}");
|
||||
expect(workflow).toContain("needs.scopes.outputs.run_ui_p0 == 'true'");
|
||||
expect(validate).toContain('when($out.run_ui_p0 == "true"; ["ui_p0"])');
|
||||
expect(plan).toContain("run: ${{ steps.hash.outputs.run }}");
|
||||
expect(plan).toContain("scopes: ${{ steps.scopes.outputs.scopes }}");
|
||||
expect(workflow).toContain("fromJSON(needs.plan.outputs.run).ui_p0");
|
||||
expect(validate).toContain("[$run | to_entries[] | select(.value) | .key]");
|
||||
|
||||
await expect(runScopesPrint("workflow_dispatch", { inputs: { ci_mode: "hot" } }, ["apps/web/src/app/page.tsx"])).resolves.toMatchObject({
|
||||
ci_mode: "hot",
|
||||
@@ -1126,12 +1118,12 @@ process.stdin.on("end", () => {
|
||||
const workflow = await readFile(ciWorkflowPath, "utf8");
|
||||
const workspaceUnit = sectionBetween(workflow, " workspace_unit_tests:", " daemon_unit_tests:");
|
||||
|
||||
expect(workspaceUnit).toContain(`if [ "\${{ needs.scopes.outputs.tools_pack_tests_required }}" = "true" ]; then
|
||||
expect(workspaceUnit).toContain(`if [ "\${{ fromJSON(needs.plan.outputs.scopes).tools_pack_tests_required }}" = "true" ]; then
|
||||
pnpm --filter @open-design/desktop build
|
||||
pnpm --filter @open-design/desktop test
|
||||
pnpm --filter @open-design/packaged test
|
||||
pnpm --filter @open-design/tools-pack test
|
||||
if [ "\${{ needs.scopes.outputs.run_e2e_vitest }}" != "true" ]; then
|
||||
if [ "\${{ fromJSON(needs.plan.outputs.run).e2e_vitest }}" != "true" ]; then
|
||||
pnpm --filter @open-design/e2e test tests/packaged-launcher-update-loop.test.ts
|
||||
fi
|
||||
fi`);
|
||||
@@ -1142,12 +1134,12 @@ process.stdin.on("end", () => {
|
||||
const daemonTests = sectionBetween(workflow, " daemon_unit_tests:", " windows_tools_pack_payload_tests:");
|
||||
const validate = sectionBetween(workflow, " validate:", " runtime_summary:");
|
||||
|
||||
expect(daemonTests).toContain("if: ${{ needs.scopes.outputs.daemon_tests_required == 'true' }}");
|
||||
expect(daemonTests).toContain("if: ${{ fromJSON(needs.plan.outputs.run).daemon_unit_tests }}");
|
||||
expect(daemonTests).toContain("fail-fast: false");
|
||||
expect(daemonTests).toContain("shard: [1, 2, 3, 4]");
|
||||
expect(daemonTests).toContain("pnpm --filter @open-design/daemon test --shard=${{ matrix.shard }}/4");
|
||||
expect(validate).toContain("- daemon_unit_tests");
|
||||
expect(validate).toContain('when($out.daemon_tests_required == "true"; ["daemon_unit_tests"])');
|
||||
expect(validate).toContain("[$run | to_entries[] | select(.value) | .key]");
|
||||
});
|
||||
|
||||
it("[P2] skips the critical fallback for pure packaged-leaf changes and stays fail-closed elsewhere", async () => {
|
||||
@@ -1262,32 +1254,66 @@ process.stdin.on("end", () => {
|
||||
expect(validate).not.toContain("run_docker_build");
|
||||
expect(validate).toContain("Check workspace validation jobs");
|
||||
|
||||
const baseOutputs = {
|
||||
run_preflight: "false",
|
||||
run_workspace_unit_tests: "false",
|
||||
run_windows_tools_pack_payload_tests: "false",
|
||||
run_web_workspace_tests: "false",
|
||||
run_e2e_vitest: "false",
|
||||
run_playwright_critical: "false",
|
||||
run_ui_p0: "false",
|
||||
run_playwright_visual: "false",
|
||||
const baseRun = {
|
||||
static_gate: false,
|
||||
preflight: false,
|
||||
workspace_unit_tests: false,
|
||||
daemon_unit_tests: false,
|
||||
windows_tools_pack_payload_tests: false,
|
||||
web_workspace_tests: false,
|
||||
e2e_vitest: false,
|
||||
playwright_critical: false,
|
||||
ui_p0: false,
|
||||
playwright_visual: false,
|
||||
};
|
||||
// Core gate only cares about app jobs — unknown packaging keys are ignored.
|
||||
await expect(
|
||||
validateGatePasses(workflow, {
|
||||
scopes: { result: "success", outputs: baseOutputs },
|
||||
static_gate: { result: "success" },
|
||||
plan: { result: "success", outputs: { run: JSON.stringify(baseRun) } },
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
const needsWithFailedWeb = {
|
||||
scopes: { result: "success", outputs: { ...baseOutputs, run_web_workspace_tests: "true" } },
|
||||
static_gate: { result: "success" },
|
||||
plan: { result: "success", outputs: { run: JSON.stringify({ ...baseRun, web_workspace_tests: true }) } },
|
||||
web_workspace_tests: { result: "failure" },
|
||||
};
|
||||
await expect(validateGatePasses(workflow, needsWithFailedWeb)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("[P1] publishes hash state only after the workspace gate succeeds", async () => {
|
||||
const workflow = await readFile(ciWorkflowPath, "utf8");
|
||||
const plan = sectionBetween(workflow, " plan:", " static_gate:");
|
||||
const validate = sectionBetween(workflow, " validate:", " runtime_summary:");
|
||||
|
||||
expect(plan).toContain("Upload pending hash map");
|
||||
expect(plan).not.toContain("actions/cache/save");
|
||||
expect(validate).toContain("Download pending hash map");
|
||||
expect(validate).toContain("Save successful hash map");
|
||||
expect(validate.indexOf("Save successful hash map")).toBeGreaterThan(
|
||||
validate.indexOf("Check workspace validation jobs"),
|
||||
);
|
||||
expect(validate.indexOf("Save successful hash map")).toBeGreaterThan(
|
||||
validate.indexOf("Block merge while a merge-blocking label is present"),
|
||||
);
|
||||
|
||||
const run = {
|
||||
static_gate: false,
|
||||
preflight: false,
|
||||
workspace_unit_tests: false,
|
||||
daemon_unit_tests: false,
|
||||
windows_tools_pack_payload_tests: false,
|
||||
web_workspace_tests: false,
|
||||
e2e_vitest: true,
|
||||
playwright_critical: false,
|
||||
ui_p0: false,
|
||||
playwright_visual: false,
|
||||
};
|
||||
await expect(validateGatePasses(workflow, {
|
||||
plan: { result: "success", outputs: { run: JSON.stringify(run) } },
|
||||
e2e_vitest: { result: "failure" },
|
||||
})).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("[P1] includes launcher protocol in the Nix daemon workspace build", async () => {
|
||||
const flake = await readFile(flakePath, "utf8");
|
||||
const daemonWorkspaces = sectionBetween(flake, " daemonWorkspacePaths = [", " ];");
|
||||
@@ -1300,8 +1326,8 @@ process.stdin.on("end", () => {
|
||||
|
||||
it("[P2] routes trusted Linux CI through the Nexu runner fleet", async () => {
|
||||
const workflow = await readFile(ciWorkflowPath, "utf8");
|
||||
const runners = sectionBetween(workflow, " runners:", " scopes:");
|
||||
const scopes = sectionBetween(workflow, " scopes:", " static_gate:");
|
||||
const runners = sectionBetween(workflow, " runners:", " plan:");
|
||||
const plan = sectionBetween(workflow, " plan:", " static_gate:");
|
||||
const staticGate = sectionBetween(workflow, " static_gate:", " preflight:");
|
||||
const workspaceUnitTests = sectionBetween(workflow, " workspace_unit_tests:", " daemon_unit_tests:");
|
||||
const daemonUnitTests = sectionBetween(workflow, " daemon_unit_tests:", " windows_tools_pack_payload_tests:");
|
||||
@@ -1316,9 +1342,9 @@ process.stdin.on("end", () => {
|
||||
expect(runners).toContain("runs_on: ${{ steps.runners.outputs.runs_on }}");
|
||||
expect(runners).toContain("decision: ${{ steps.runners.outputs.decision }}");
|
||||
expect(runners).toContain("python3 .github/scripts/runners.py");
|
||||
expect(scopes).toContain("needs: [runners]");
|
||||
expect(scopes).toContain("fromJSON(needs.runners.outputs.runs_on).control");
|
||||
expect(staticGate).toContain("needs: [runners]");
|
||||
expect(plan).toContain("needs: [runners]");
|
||||
expect(plan).toContain("fromJSON(needs.runners.outputs.runs_on).control");
|
||||
expect(staticGate).toContain("needs: [plan, runners]");
|
||||
expect(staticGate).toContain("fromJSON(needs.runners.outputs.runs_on).control");
|
||||
expect(workspaceUnitTests).toContain("fromJSON(needs.runners.outputs.runs_on).workspace_unit");
|
||||
expect(workspaceUnitTests).toContain("toJSON(fromJSON(needs.runners.outputs.runs_on).workspace_unit)");
|
||||
@@ -1345,7 +1371,7 @@ process.stdin.on("end", () => {
|
||||
expect(uiP0).toContain(
|
||||
"toJSON(matrix.shard == 'project-collab' && fromJSON(needs.runners.outputs.runs_on).ui_p0_heavy || fromJSON(needs.runners.outputs.runs_on).ui_p0)",
|
||||
);
|
||||
expect(uiP0).toContain("include: ${{ fromJSON(needs.scopes.outputs.ui_p0_matrix) }}");
|
||||
expect(uiP0).toContain("include: ${{ fromJSON(needs.plan.outputs.ui_p0_matrix) }}");
|
||||
expect(uiP0CiMatrix.map((entry) => entry.name)).toEqual([
|
||||
"entry-settings",
|
||||
"project-workspace",
|
||||
@@ -1453,7 +1479,7 @@ process.stdin.on("end", () => {
|
||||
|
||||
it("[P1] routes external fork PRs through GitHub-hosted runner profiles", async () => {
|
||||
const workflow = await readFile(ciWorkflowPath, "utf8");
|
||||
const runners = sectionBetween(workflow, " runners:", " scopes:");
|
||||
const runners = sectionBetween(workflow, " runners:", " plan:");
|
||||
|
||||
expect(runners).toContain("github.event_name == 'pull_request'");
|
||||
expect(runners).toContain("github.event.pull_request.head.repo.full_name != github.repository");
|
||||
@@ -1628,7 +1654,7 @@ process.stdin.on("end", () => {
|
||||
|
||||
const performanceProfiles = await runRunners("performance");
|
||||
const performanceRunsOn = runnerRunsOn(performanceProfiles);
|
||||
expect(runnerDecision(performanceProfiles)).toEqual({ schema_version: 1, mode: "performance" });
|
||||
expect(runnerDecision(performanceProfiles)).toEqual({ schema_version: 1, mode: "default" });
|
||||
expect(performanceRunsOn.control).toEqual(["nexu-runners-small"]);
|
||||
expect(performanceRunsOn.general_medium).toEqual(["nexu-runners-medium"]);
|
||||
expect(performanceRunsOn.workspace_unit).toEqual(["nexu-runners-medium"]);
|
||||
@@ -1666,9 +1692,7 @@ process.stdin.on("end", () => {
|
||||
expect(economicRunsOn.visual_hot).toEqual(["ubuntu-24.04"]);
|
||||
|
||||
for (const invalidMode of ["Economic", " economic "]) {
|
||||
const fallbackProfiles = await runRunners(invalidMode);
|
||||
expect(runnerDecision(fallbackProfiles)).toEqual({ schema_version: 1, mode: "default" });
|
||||
expect(runnerRunsOn(fallbackProfiles).control).toEqual(["nexu-runners-small"]);
|
||||
await expect(runRunners(invalidMode)).rejects.toThrow("unknown runner mode");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,16 +18,14 @@ describe("scripts guard library", () => {
|
||||
expect(scriptsArchitectureErrors(sources)).toEqual([]);
|
||||
});
|
||||
|
||||
test("scope startup cannot reach guard internals or package dependencies", () => {
|
||||
test("root scripts cannot bypass the public guard entrypoint", () => {
|
||||
const sources = new Map([
|
||||
["scripts/scopes.ts", 'import "./lib/guard/core.ts";\nimport "typescript";'],
|
||||
["scripts/check.ts", 'import "./lib/guard/core.ts";'],
|
||||
["scripts/lib/guard/core.ts", ""],
|
||||
["scripts/guard.ts", 'import "./lib/guard/core.ts";'],
|
||||
["e2e/lib/playwright/suites.ts", ""],
|
||||
]);
|
||||
expect(scriptsArchitectureErrors(sources)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("install-independent scope closure"),
|
||||
expect.stringContaining("must consume guard policy through scripts/guard.ts"),
|
||||
]),
|
||||
);
|
||||
@@ -35,11 +33,9 @@ describe("scripts guard library", () => {
|
||||
|
||||
test("guard internals cannot reverse-depend on the CLI or form cycles", () => {
|
||||
const sources = new Map([
|
||||
["scripts/scopes.ts", 'import "../e2e/lib/playwright/suites.ts";'],
|
||||
["scripts/guard.ts", 'import "./lib/guard/core.ts";'],
|
||||
["scripts/lib/guard/core.ts", 'import "./architecture.ts";'],
|
||||
["scripts/lib/guard/architecture.ts", 'import "./core.ts";\nimport "../../guard.ts";\nprocess.exitCode = 1;'],
|
||||
["e2e/lib/playwright/suites.ts", ""],
|
||||
]);
|
||||
const errors = scriptsArchitectureErrors(sources);
|
||||
expect(errors).toEqual(
|
||||
@@ -78,6 +74,5 @@ describe("scripts guard library", () => {
|
||||
encoding: "utf8",
|
||||
}).trim().split("\n");
|
||||
expect(names).toContain("scripts library architecture");
|
||||
expect(names).toContain("daemon core boundary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const hashScript = path.join(repoRoot, ".github/scripts/hash.py");
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
function createRepository() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "hash-contract-"));
|
||||
temporaryRoots.push(root);
|
||||
for (const [name, content] of [["control.txt", "control"], ["a.txt", "a"], ["b.txt", "b"]] as const) {
|
||||
writeFileSync(path.join(root, name), content);
|
||||
}
|
||||
execFileSync("git", ["init", "-q"], { cwd: root });
|
||||
execFileSync("git", ["add", "."], { cwd: root });
|
||||
const configPath = path.join(root, "hash.json");
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
schema: { version: 1 },
|
||||
suites: { "ci-control": ["control.txt"], web: ["a.txt"] },
|
||||
workflows: { ci: { a: ["suite://web"], b: ["key://ci/a", "b.txt"], all: ["*"] } },
|
||||
}));
|
||||
const scopePlanPath = path.join(root, "scope-plan.json");
|
||||
writeFileSync(scopePlanPath, JSON.stringify({ enabled: { a: true, b: true, all: true } }));
|
||||
return { root, configPath, scopePlanPath, statePath: path.join(root, "state.json") };
|
||||
}
|
||||
|
||||
function runHash(fixture: ReturnType<typeof createRepository>) {
|
||||
const outputPath = path.join(fixture.root, "github-output.txt");
|
||||
writeFileSync(outputPath, "");
|
||||
const stdout = execFileSync("python3", [
|
||||
hashScript, "--root", fixture.root, "--config", fixture.configPath,
|
||||
"github-output", "--workflow", "ci", "--scope-plan", fixture.scopePlanPath, "--state", fixture.statePath,
|
||||
], { cwd: fixture.root, encoding: "utf8", env: { ...process.env, GITHUB_OUTPUT: outputPath } });
|
||||
return {
|
||||
decision: JSON.parse(stdout) as { run: Record<string, boolean>; equal: Record<string, boolean> },
|
||||
state: JSON.parse(readFileSync(fixture.statePath, "utf8")) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("hash input register", () => {
|
||||
test("starts cold, compares hot, and rewrites only the current map", () => {
|
||||
const fixture = createRepository();
|
||||
expect(runHash(fixture).decision.run).toEqual({ a: true, all: true, b: true });
|
||||
const hot = runHash(fixture);
|
||||
expect(hot.decision.run).toEqual({ a: false, all: false, b: false });
|
||||
expect(hot.state).toEqual({
|
||||
schemaVersion: 1,
|
||||
workflow: "ci",
|
||||
hashes: expect.objectContaining({ a: expect.any(String), all: expect.any(String), b: expect.any(String) }),
|
||||
});
|
||||
});
|
||||
|
||||
test("composes suite and key dependencies while star observes the whole tree", () => {
|
||||
const fixture = createRepository();
|
||||
runHash(fixture);
|
||||
writeFileSync(path.join(fixture.root, "b.txt"), "b2");
|
||||
execFileSync("git", ["add", "b.txt"], { cwd: fixture.root });
|
||||
expect(runHash(fixture).decision.run).toEqual({ a: false, all: true, b: true });
|
||||
|
||||
writeFileSync(path.join(fixture.root, "a.txt"), "a2");
|
||||
execFileSync("git", ["add", "a.txt"], { cwd: fixture.root });
|
||||
expect(runHash(fixture).decision.run).toEqual({ a: true, all: true, b: true });
|
||||
});
|
||||
|
||||
test("corrupt or missing state fails cold without weakening the plan", () => {
|
||||
const fixture = createRepository();
|
||||
runHash(fixture);
|
||||
writeFileSync(fixture.statePath, "not json");
|
||||
expect(runHash(fixture).decision.run).toEqual({ a: true, all: true, b: true });
|
||||
});
|
||||
|
||||
test("rejects cycles through the implicit control closure before evaluation", () => {
|
||||
const fixture = createRepository();
|
||||
writeFileSync(fixture.configPath, JSON.stringify({
|
||||
schema: { version: 1 },
|
||||
suites: { "ci-control": ["key://ci/a"] },
|
||||
workflows: { ci: { a: ["a.txt"] } },
|
||||
}));
|
||||
const failed = spawnSync("python3", [hashScript, "--root", fixture.root, "--config", fixture.configPath, "validate"], {
|
||||
cwd: fixture.root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(failed.status).toBe(2);
|
||||
expect(failed.stderr).toContain("hash dependency cycle");
|
||||
|
||||
writeFileSync(fixture.configPath, JSON.stringify({
|
||||
schema: { version: 1 },
|
||||
suites: { "ci-control": ["control.txt"] },
|
||||
workflows: { ci: { a: ["suite://missing"] } },
|
||||
}));
|
||||
const dangling = spawnSync("python3", [hashScript, "--root", fixture.root, "--config", fixture.configPath, "validate"], {
|
||||
cwd: fixture.root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(dangling.status).toBe(2);
|
||||
expect(dangling.stderr).toContain("references unknown suite://missing");
|
||||
});
|
||||
});
|
||||
+95
-1023
File diff suppressed because it is too large
Load Diff
@@ -40,16 +40,15 @@ import { describe, expect, it } from 'vitest';
|
||||
* incidental:
|
||||
*
|
||||
* - Any change under `apps/daemon/src/` matches the `certain-daemon-core` rule
|
||||
* in `scripts/scopes.ts`, whose effects include `ui_p0_validation_required`;
|
||||
* in `.github/config/scopes.json`, whose effects include `ui_p0_validation_required`;
|
||||
* `run_e2e_vitest` is `isFull || web_tests_required ||
|
||||
* ui_p0_validation_required`. So a `server.ts`-only change arms the `E2E
|
||||
* Vitest` lane even at the merge queue's `certain` threshold — the strictest
|
||||
* context there is. An unresolved file list escalates fail-closed to full,
|
||||
* which arms it too.
|
||||
* - That wiring cannot rot silently. The `daemon core boundary` guard
|
||||
* (`scripts/lib/guard/scope.ts`) asserts `ci.yml` still contains both
|
||||
* `run_e2e_vitest == 'true'` and `pnpm --filter @open-design/e2e test`, and
|
||||
* it runs in the always-on policy floor.
|
||||
* - Direct `scopes.py plan` tests pin that routing, while the workflow topology
|
||||
* test pins the `e2e_vitest` hash-run identity and package command. Those are
|
||||
* planner contract tests, not an independent proof that authorizes the plan.
|
||||
*
|
||||
* The placement also follows the root `AGENTS.md` boundary rule — cross-app and
|
||||
* repository-resource consistency checks belong here, not inside an app package
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
import {
|
||||
CERTAIN_DAEMON_CORE_EXACT,
|
||||
CERTAIN_EXEMPT_EXACT,
|
||||
CERTAIN_EXEMPT_PREFIXES,
|
||||
} from "./scopes.ts";
|
||||
|
||||
// Guard for the certain-tier exempt core in `scripts/scopes.ts` (rule
|
||||
// `certain-exempt-surface`; methodology in `specs/current/ci.md`).
|
||||
//
|
||||
// Boundary invariant: no source that a *skippable* merge-gate lane executes may
|
||||
// consume a certain-exempt file. If that held false, a docs-only change could
|
||||
// invalidate a lane the promoted rule tells the merge queue to skip.
|
||||
//
|
||||
// What "consumption" means here, at two precision levels:
|
||||
// - a dot-relative literal (`../../docs/...`) that resolves from the file's
|
||||
// repo location into the certain-exempt surface: flagged everywhere — it can
|
||||
// only mean the repository surface.
|
||||
// - a bare repo-relative literal (`docs/CHANGELOG`): flagged everywhere unless
|
||||
// it is an argument to a known sandbox-fixture writer. Test files can also
|
||||
// contain repo-root helpers, so exempting them wholesale would hide real
|
||||
// consumption.
|
||||
// - path.join/path.resolve expressions made from static segments (optionally
|
||||
// anchored at repoRoot), and template literals whose static prefix already
|
||||
// enters an exempt directory: flagged as the same repository dependency.
|
||||
//
|
||||
// Deliberately outside the checked surface:
|
||||
// - root `scripts/` — policy-floor code. Preflight is armed on every plan, so
|
||||
// its guard checks may read the exempt surface (product neutrality validates
|
||||
// docs/ prose on every run).
|
||||
// - `apps/landing-page/` — it IS the exempt surface; landing-page CI owns it.
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
const checkedRoots = ["apps", "packages", "tools", "e2e"] as const;
|
||||
|
||||
const skippedDirectoryNames = new Set([
|
||||
".astro",
|
||||
".next",
|
||||
".od-data",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
"reports",
|
||||
"test-results",
|
||||
"vendor",
|
||||
]);
|
||||
|
||||
const skippedRepositoryPrefixes = ["apps/landing-page/"];
|
||||
|
||||
const checkedExtensions = new Set([".ts", ".tsx"]);
|
||||
|
||||
// These helpers interpret their path argument inside a temporary project
|
||||
// workspace. Keep this list narrow: repo-root readers used by tests must remain
|
||||
// visible to the guard.
|
||||
const sandboxFixtureWriterNames = new Set(["writeProjectFile"]);
|
||||
|
||||
// File-level exceptions. Every entry must explain why the reference is not
|
||||
// gate-lane consumption of exempt-file *content*; revisit the entry if the
|
||||
// file's relationship to the exempt surface changes.
|
||||
const allowedConsumers = new Map<string, string>([
|
||||
[
|
||||
"apps/daemon/tests/claude-design-import.test.ts",
|
||||
"archive-entry and extracted-project fixture paths; every docs/ literal names data inside a temporary project",
|
||||
],
|
||||
[
|
||||
"apps/daemon/tests/design-systems/file-score.test.ts",
|
||||
"path-classification fixture strings; the test does not open the editor configuration files",
|
||||
],
|
||||
[
|
||||
"apps/daemon/tests/project-classifiers.test.ts",
|
||||
"file-kind classifier input; the LICENSE literal is never resolved or opened",
|
||||
],
|
||||
[
|
||||
"apps/web/tests/components/ChatPane.imported-folder-artifacts.test.tsx",
|
||||
"imported-project artifact fixture paths rendered from in-memory test data",
|
||||
],
|
||||
[
|
||||
"apps/web/tests/components/file-viewer-markdown-copy.test.tsx",
|
||||
"project-relative markdown path inputs used to test URL construction, not repository reads",
|
||||
],
|
||||
[
|
||||
"apps/web/tests/utils/inlineMentions.test.ts",
|
||||
"in-memory mention parser fixture paths; no filesystem access occurs",
|
||||
],
|
||||
[
|
||||
"tools/release/src/release-note/prepare.ts",
|
||||
"docs/CHANGELOG feeds release-note preparation, which runs only in release workflows; @open-design/tools-release tests run in no ci.yml lane",
|
||||
],
|
||||
[
|
||||
"e2e/tests/packaged-smoke-workflow.test.ts",
|
||||
"scope-planner and workflow-text assertion fixtures; certain-exempt literals are passed as data and never opened",
|
||||
],
|
||||
[
|
||||
"e2e/tests/scripts/product-neutrality.test.ts",
|
||||
"virtual source path passed to the product-neutrality collector; it does not access the repository file",
|
||||
],
|
||||
[
|
||||
"e2e/tests/scripts/scopes.test.ts",
|
||||
"behavior fixtures for this very check: source snippets passed to the collector as data, never resolved or opened",
|
||||
],
|
||||
]);
|
||||
|
||||
// Content dependencies whose producers are classified into the same certain
|
||||
// lane as their consumers. Unlike allowedConsumers, these exceptions are
|
||||
// exact producer/consumer pairs so another exempt read in the same file still
|
||||
// fails closed.
|
||||
const allowedConsumerTargets = new Map<string, ReadonlyMap<string, string>>([
|
||||
[
|
||||
"apps/daemon/tests/runtimes/trae-cli.test.ts",
|
||||
new Map([
|
||||
[
|
||||
CERTAIN_DAEMON_CORE_EXACT[0],
|
||||
"the exact consumed document is daemon core, so producer and consumer run the same suite",
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
type ConsumptionViolation = {
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
literal: string;
|
||||
repositoryTarget: string;
|
||||
};
|
||||
|
||||
function toRepositoryPath(filePath: string): string {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function landsInCertainExemptSurface(repositoryPath: string): boolean {
|
||||
return (
|
||||
CERTAIN_EXEMPT_PREFIXES.some((prefix) => repositoryPath.startsWith(prefix)) ||
|
||||
(CERTAIN_EXEMPT_EXACT as readonly string[]).includes(repositoryPath)
|
||||
);
|
||||
}
|
||||
|
||||
function literalConsumesCertainExemptSurface(fromRepositoryPath: string, literal: string): boolean {
|
||||
if (literal.startsWith("./") || literal.startsWith("../")) {
|
||||
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromRepositoryPath), literal));
|
||||
return landsInCertainExemptSurface(resolved);
|
||||
}
|
||||
return landsInCertainExemptSurface(literal);
|
||||
}
|
||||
|
||||
function isSandboxFixtureWriterArgument(node: ts.Expression): boolean {
|
||||
const call = node.parent;
|
||||
if (!ts.isCallExpression(call) || !call.arguments.includes(node)) return false;
|
||||
|
||||
const callee = call.expression;
|
||||
const name = ts.isIdentifier(callee)
|
||||
? callee.text
|
||||
: ts.isPropertyAccessExpression(callee)
|
||||
? callee.name.text
|
||||
: undefined;
|
||||
return name !== undefined && sandboxFixtureWriterNames.has(name);
|
||||
}
|
||||
|
||||
type StaticPath = {
|
||||
path: string;
|
||||
display: string;
|
||||
prefixOnly: boolean;
|
||||
};
|
||||
|
||||
function staticPathFromExpression(node: ts.Expression, sourceFile: ts.SourceFile): StaticPath | undefined {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return { path: node.text, display: node.text, prefixOnly: false };
|
||||
}
|
||||
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
const prefix = node.head.text;
|
||||
if (!CERTAIN_EXEMPT_PREFIXES.some((exemptPrefix) => prefix.startsWith(exemptPrefix))) {
|
||||
return undefined;
|
||||
}
|
||||
return { path: prefix, display: node.getText(sourceFile), prefixOnly: true };
|
||||
}
|
||||
|
||||
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return undefined;
|
||||
const callee = node.expression;
|
||||
if (
|
||||
!ts.isIdentifier(callee.expression) ||
|
||||
callee.expression.text !== "path" ||
|
||||
(callee.name.text !== "join" && callee.name.text !== "resolve")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
for (const [index, argument] of node.arguments.entries()) {
|
||||
if (index === 0 && ts.isIdentifier(argument) && argument.text === "repoRoot") continue;
|
||||
const segment = staticPathFromExpression(argument, sourceFile);
|
||||
if (segment === undefined || segment.prefixOnly) return undefined;
|
||||
segments.push(segment.path);
|
||||
}
|
||||
if (segments.length === 0) return undefined;
|
||||
|
||||
return {
|
||||
path: path.posix.join(...segments),
|
||||
display: node.getText(sourceFile),
|
||||
prefixOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectCertainExemptConsumptionFromSource(
|
||||
repositoryPath: string,
|
||||
source: string,
|
||||
): ConsumptionViolation[] {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
repositoryPath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
repositoryPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const violations: ConsumptionViolation[] = [];
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isExpression(node)) {
|
||||
const candidate = staticPathFromExpression(node, sourceFile);
|
||||
if (candidate !== undefined && !isSandboxFixtureWriterArgument(node)) {
|
||||
const consumes = candidate.prefixOnly
|
||||
? CERTAIN_EXEMPT_PREFIXES.some((prefix) => candidate.path.startsWith(prefix))
|
||||
: literalConsumesCertainExemptSurface(repositoryPath, candidate.path);
|
||||
if (consumes) {
|
||||
violations.push({
|
||||
filePath: repositoryPath,
|
||||
lineNumber: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
|
||||
literal: candidate.display,
|
||||
repositoryTarget: candidate.path,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function collectDisallowedCertainExemptConsumptionFromSource(
|
||||
repositoryPath: string,
|
||||
source: string,
|
||||
): ConsumptionViolation[] {
|
||||
if (allowedConsumers.has(repositoryPath)) return [];
|
||||
const allowedTargets = allowedConsumerTargets.get(repositoryPath);
|
||||
return collectCertainExemptConsumptionFromSource(repositoryPath, source).filter(
|
||||
(violation) => !allowedTargets?.has(violation.repositoryTarget),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectCheckedFiles(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
const repositoryPath = toRepositoryPath(fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (
|
||||
skippedDirectoryNames.has(entry.name) ||
|
||||
entry.name.startsWith(".next-") ||
|
||||
skippedRepositoryPrefixes.some((prefix) => `${repositoryPath}/`.startsWith(prefix))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
files.push(...(await collectCheckedFiles(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && checkedExtensions.has(path.extname(entry.name))) {
|
||||
files.push(repositoryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function checkCertainExemptConsumption(): Promise<boolean> {
|
||||
const violations: ConsumptionViolation[] = [];
|
||||
|
||||
for (const root of checkedRoots) {
|
||||
for (const repositoryPath of await collectCheckedFiles(path.join(repoRoot, root))) {
|
||||
const source = await readFile(path.join(repoRoot, repositoryPath), "utf8");
|
||||
violations.push(...collectDisallowedCertainExemptConsumptionFromSource(repositoryPath, source));
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Certain-exempt surface consumption found in gate-lane sources:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation.filePath}:${violation.lineNumber} \`${violation.literal}\``);
|
||||
}
|
||||
console.error(
|
||||
"Certain-tier exempt files must stay unconsumed by skippable merge-gate lanes (see specs/current/ci.md). Move the dependency, or add a justified allowlist entry in scripts/check-certain-exempt-consumption.ts.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Certain-exempt consumption check passed: gate-lane sources do not read the certain-exempt surface.");
|
||||
return true;
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
import {
|
||||
CERTAIN_PACKAGED_LEAF_PREFIXES,
|
||||
evaluateScopeOutputs,
|
||||
SCOPE_EFFECTS,
|
||||
} from "./scopes.ts";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const checkedRoots = ["apps", "packages", "tools", "e2e"] as const;
|
||||
const checkedExtensions = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
|
||||
const skippedDirectories = new Set([
|
||||
".astro",
|
||||
".next",
|
||||
".od-data",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
"reports",
|
||||
"test-results",
|
||||
"vendor",
|
||||
]);
|
||||
const leafPackages = ["@open-design/desktop", "@open-design/packaged", "@open-design/tools-pack"] as const;
|
||||
|
||||
const allowedConsumerPrefixes = new Map([
|
||||
[
|
||||
"tools/dev/",
|
||||
"tools-dev launches the desktop entry; the certain rule keeps @open-design/tools-dev tests armed",
|
||||
],
|
||||
]);
|
||||
|
||||
const allowedConsumers = new Map([
|
||||
[
|
||||
"apps/packaged/esbuild.config.mjs",
|
||||
"the packaged build owns these entrypoints; the config itself remains medium-tier",
|
||||
],
|
||||
[
|
||||
"tools/pack/esbuild.config.mjs",
|
||||
"the tools-pack build owns this entrypoint; the config itself remains medium-tier",
|
||||
],
|
||||
[
|
||||
"e2e/tests/packaged-launcher-update-loop.test.ts",
|
||||
"the focused cross-boundary fallback runs whenever broad E2E Vitest is skipped",
|
||||
],
|
||||
[
|
||||
"e2e/tests/packaged-smoke-workflow.test.ts",
|
||||
"workflow and scope topology fixtures; packaged paths are data rather than runtime imports",
|
||||
],
|
||||
[
|
||||
"e2e/tests/scripts/approve-fork-pr-workflows.test.ts",
|
||||
"fork-approval path classification fixtures; packaged paths are data",
|
||||
],
|
||||
[
|
||||
"e2e/tests/scripts/check-cross-app-imports.test.ts",
|
||||
"cross-app import parser fixtures; the packaged import is passed as source text",
|
||||
],
|
||||
[
|
||||
"e2e/tests/scripts/scopes.test.ts",
|
||||
"scope planner fixtures; packaged paths are classification inputs",
|
||||
],
|
||||
[
|
||||
"packages/platform/tests/index.test.ts",
|
||||
"package-manager invocation fixtures serialize the desktop build command without loading desktop code",
|
||||
],
|
||||
]);
|
||||
|
||||
const requiredWorkspaceUnitBlock = `if [ "\${{ needs.scopes.outputs.tools_dev_tests_required }}" = "true" ]; then
|
||||
pnpm --filter @open-design/tools-dev test
|
||||
fi
|
||||
if [ "\${{ needs.scopes.outputs.tools_pack_tests_required }}" = "true" ]; then
|
||||
pnpm --filter @open-design/desktop build
|
||||
pnpm --filter @open-design/desktop test
|
||||
pnpm --filter @open-design/packaged test
|
||||
pnpm --filter @open-design/tools-pack test
|
||||
if [ "\${{ needs.scopes.outputs.run_e2e_vitest }}" != "true" ]; then
|
||||
pnpm --filter @open-design/e2e test tests/packaged-launcher-update-loop.test.ts
|
||||
fi
|
||||
fi`;
|
||||
|
||||
export type PackagedLeafConsumptionViolation = {
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
literal: string;
|
||||
};
|
||||
|
||||
function repositoryPath(filePath: string): string {
|
||||
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function landsInPackagedLeaf(value: string): boolean {
|
||||
return CERTAIN_PACKAGED_LEAF_PREFIXES.some(
|
||||
(prefix) => value === prefix.slice(0, -1) || value.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
|
||||
function literalTargetsPackagedLeaf(fromRepositoryPath: string, literal: string): boolean {
|
||||
if (leafPackages.some((packageName) => literal === packageName || literal.startsWith(`${packageName}/`))) {
|
||||
return true;
|
||||
}
|
||||
const resolved =
|
||||
literal.startsWith("./") || literal.startsWith("../")
|
||||
? path.posix.normalize(path.posix.join(path.posix.dirname(fromRepositoryPath), literal))
|
||||
: literal;
|
||||
return landsInPackagedLeaf(resolved);
|
||||
}
|
||||
|
||||
type StaticPath = { value: string; display: string };
|
||||
|
||||
function staticPath(node: ts.Expression, sourceFile: ts.SourceFile): StaticPath | undefined {
|
||||
if (ts.isStringLiteralLike(node)) return { value: node.text, display: node.getText(sourceFile) };
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
return { value: node.head.text, display: node.getText(sourceFile) };
|
||||
}
|
||||
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return undefined;
|
||||
if (
|
||||
!ts.isIdentifier(node.expression.expression) ||
|
||||
node.expression.expression.text !== "path" ||
|
||||
(node.expression.name.text !== "join" && node.expression.name.text !== "resolve")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
for (const [index, argument] of node.arguments.entries()) {
|
||||
if (index === 0 && ts.isIdentifier(argument) && /^(?:repoRoot|workspaceRoot|WORKSPACE_ROOT)$/.test(argument.text)) {
|
||||
continue;
|
||||
}
|
||||
if (!ts.isExpression(argument)) return undefined;
|
||||
const segment = staticPath(argument, sourceFile);
|
||||
if (segment == null || segment.value.length === 0) return undefined;
|
||||
segments.push(segment.value);
|
||||
}
|
||||
return segments.length > 0
|
||||
? { value: path.posix.join(...segments), display: node.getText(sourceFile) }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function collectPackagedLeafConsumptionFromSource(
|
||||
filePath: string,
|
||||
source: string,
|
||||
): PackagedLeafConsumptionViolation[] {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const violations: PackagedLeafConsumptionViolation[] = [];
|
||||
const seen = new Set<number>();
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isExpression(node)) {
|
||||
const candidate = staticPath(node, sourceFile);
|
||||
if (candidate != null && literalTargetsPackagedLeaf(filePath, candidate.value)) {
|
||||
const start = node.getStart(sourceFile);
|
||||
if (!seen.has(start)) {
|
||||
seen.add(start);
|
||||
violations.push({
|
||||
filePath,
|
||||
lineNumber: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
|
||||
literal: candidate.display,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return violations;
|
||||
}
|
||||
|
||||
function isAllowedConsumer(filePath: string): boolean {
|
||||
return (
|
||||
allowedConsumers.has(filePath) ||
|
||||
[...allowedConsumerPrefixes.keys()].some((prefix) => filePath.startsWith(prefix))
|
||||
);
|
||||
}
|
||||
|
||||
async function collectCheckedFiles(directory: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
const repoPath = repositoryPath(fullPath);
|
||||
if (entry.isDirectory()) {
|
||||
if (
|
||||
skippedDirectories.has(entry.name) ||
|
||||
entry.name.startsWith(".next-") ||
|
||||
landsInPackagedLeaf(`${repoPath}/`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
files.push(...(await collectCheckedFiles(fullPath)));
|
||||
} else if (entry.isFile() && checkedExtensions.has(path.extname(entry.name))) {
|
||||
files.push(repoPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function scopeBoundaryErrors(): string[] {
|
||||
const expectedEffects = new Set([
|
||||
"tools_dev_tests_required",
|
||||
"tools_pack_tests_required",
|
||||
"workspace_validation_required",
|
||||
]);
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const filePath of [
|
||||
"apps/desktop/src/main/index.ts",
|
||||
"apps/desktop/tests/main/runtime.test.ts",
|
||||
"apps/packaged/src/index.ts",
|
||||
"apps/packaged/tests/index.test.ts",
|
||||
"tools/pack/src/index.ts",
|
||||
"tools/pack/tests/index.test.ts",
|
||||
"tools/pack/resources/linux/open-design.desktop.template",
|
||||
]) {
|
||||
const evaluation = evaluateScopeOutputs([filePath], "certain", {
|
||||
deriveWorkspaceValidationFromTestScopes: true,
|
||||
});
|
||||
const decision = evaluation.decisions[0];
|
||||
const actualEffects = SCOPE_EFFECTS.filter((effect) => evaluation.outputs[effect]);
|
||||
if (
|
||||
decision?.escalated !== false ||
|
||||
decision.matchedRules.length !== 1 ||
|
||||
decision.matchedRules[0] !== "certain-packaged-leaf-sources" ||
|
||||
actualEffects.length !== expectedEffects.size ||
|
||||
actualEffects.some((effect) => !expectedEffects.has(effect))
|
||||
) {
|
||||
errors.push(`${filePath} does not resolve to the guarded packaged-leaf effects at the certain threshold`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function checkPackagedLeafBoundary(): Promise<boolean> {
|
||||
const violations: PackagedLeafConsumptionViolation[] = [];
|
||||
for (const root of checkedRoots) {
|
||||
for (const filePath of await collectCheckedFiles(path.join(repoRoot, root))) {
|
||||
if (isAllowedConsumer(filePath)) continue;
|
||||
const source = await readFile(path.join(repoRoot, filePath), "utf8");
|
||||
violations.push(...collectPackagedLeafConsumptionFromSource(filePath, source));
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = await readFile(path.join(repoRoot, ".github/workflows/ci.yml"), "utf8");
|
||||
const errors = scopeBoundaryErrors();
|
||||
if (!workflow.includes(requiredWorkspaceUnitBlock)) {
|
||||
errors.push("ci.yml no longer contains the guarded tools-dev, packaged unit, and focused E2E command block");
|
||||
}
|
||||
|
||||
if (violations.length > 0 || errors.length > 0) {
|
||||
console.error("Packaged-leaf boundary violations found:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation.filePath}:${violation.lineNumber} ${violation.literal}`);
|
||||
}
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
console.error("Keep new consumers inside the guarded validation plan or leave the changed surface at medium confidence.");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Packaged-leaf boundary check passed: certain-tier consumers, effects, and CI commands stay aligned.");
|
||||
return true;
|
||||
}
|
||||
+1
-54
@@ -3,9 +3,7 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
import { checkCertainExemptConsumption } from "./check-certain-exempt-consumption.ts";
|
||||
import { checkCrossAppImports } from "./check-cross-app-imports.ts";
|
||||
import { checkPackagedLeafBoundary } from "./check-packaged-leaf-boundary.ts";
|
||||
import { checkTsNocheckImports } from "./check-ts-nocheck-imports.ts";
|
||||
import { checkDesignSystemManifests } from "./check-design-system-manifests.ts";
|
||||
import { checkDesignSystemPackageQuality } from "./check-design-system-package-quality.ts";
|
||||
@@ -13,7 +11,6 @@ import { checkDesignSystemComponentFixtureReport } from "./check-components-fixt
|
||||
import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts";
|
||||
import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts";
|
||||
import { checkPluginPreviewManifest } from "./check-plugin-preview-manifest.ts";
|
||||
import { validatePlaywrightSuiteTopology } from "../e2e/lib/playwright/suites.ts";
|
||||
import {
|
||||
checkDesignSystemA1RequiredTokens,
|
||||
checkDesignSystemA2DefaultsParity,
|
||||
@@ -26,10 +23,6 @@ import { checkCraftReferences } from "./lint-craft-references.ts";
|
||||
import { collectCssHardcodedColorMatches, cssWideAndSpecialColorKeywords, realNamedColors } from "./style-policy.ts";
|
||||
import { checkScriptsLibraryArchitecture } from "./lib/guard/architecture.ts";
|
||||
import { runGuardChecks, type GuardCheck, type GuardContext } from "./lib/guard/core.ts";
|
||||
import {
|
||||
checkDaemonCoreBoundary as checkDaemonCoreScopeBoundary,
|
||||
checkUiP0ShadowContract,
|
||||
} from "./lib/guard/scope.ts";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
const allowedE2eScripts = new Set([
|
||||
@@ -1294,37 +1287,6 @@ async function checkStylePolicy(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function checkCiTopology(): Promise<boolean> {
|
||||
const ciWorkflow = await readFile(path.join(repoRoot, ".github/workflows/ci.yml"), "utf8");
|
||||
const errors = [
|
||||
...validatePlaywrightSuiteTopology(),
|
||||
...[
|
||||
"run: node --experimental-strip-types scripts/scopes.ts github-output",
|
||||
"ci_mode: ${{ steps.detect.outputs.ci_mode }}",
|
||||
"ui_p0_validation_required: ${{ steps.detect.outputs.ui_p0_validation_required }}",
|
||||
"run_ui_p0: ${{ steps.detect.outputs.run_ui_p0 }}",
|
||||
"ui_p0_matrix: ${{ steps.detect.outputs.ui_p0_matrix }}",
|
||||
"visual_matrix: ${{ steps.detect.outputs.visual_matrix }}",
|
||||
"include: ${{ fromJSON(needs.scopes.outputs.ui_p0_matrix) }}",
|
||||
"include: ${{ fromJSON(needs.scopes.outputs.visual_matrix) }}",
|
||||
"needs.scopes.outputs.run_ui_p0 == 'true'",
|
||||
"pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group critical-extras",
|
||||
"pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group ${{ matrix.shard }}",
|
||||
]
|
||||
.filter((needle) => !ciWorkflow.includes(needle))
|
||||
.map((needle) => `.github/workflows/ci.yml is missing ${needle}`),
|
||||
];
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("CI topology check failed:");
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("CI topology check passed: scopes, Playwright suites, and workflow matrices stay aligned.");
|
||||
return true;
|
||||
}
|
||||
|
||||
let crossAppImportsResult: Promise<boolean> | undefined;
|
||||
|
||||
function checkCrossAppImportsOnce(): Promise<boolean> {
|
||||
@@ -1332,20 +1294,8 @@ function checkCrossAppImportsOnce(): Promise<boolean> {
|
||||
return crossAppImportsResult;
|
||||
}
|
||||
|
||||
async function checkDaemonCoreBoundary(context: GuardContext): Promise<boolean> {
|
||||
const [crossAppImportsPass, scopeBoundaryPass] = await Promise.all([
|
||||
checkCrossAppImportsOnce(),
|
||||
checkDaemonCoreScopeBoundary(context),
|
||||
]);
|
||||
return crossAppImportsPass && scopeBoundaryPass;
|
||||
}
|
||||
|
||||
const checks: GuardCheck[] = [
|
||||
{ name: "residual JavaScript", run: checkResidualJavaScript },
|
||||
{ name: "certain-exempt surface consumption", run: checkCertainExemptConsumption },
|
||||
{ name: "packaged leaf boundary", run: checkPackagedLeafBoundary },
|
||||
{ name: "daemon core boundary", run: checkDaemonCoreBoundary },
|
||||
{ name: "UI P0 shadow contract", run: checkUiP0ShadowContract },
|
||||
{ name: "package dependency specs", run: checkPackageDependencySpecs },
|
||||
{ name: "product neutrality", run: checkProductNeutrality },
|
||||
{ name: "cross-app imports", run: checkCrossAppImportsOnce },
|
||||
@@ -1358,7 +1308,6 @@ const checks: GuardCheck[] = [
|
||||
{ name: "web import isolation", run: checkWebImportIsolation },
|
||||
{ name: "tools layout", run: checkToolsLayout },
|
||||
{ name: "style policy", run: checkStylePolicy },
|
||||
{ name: "CI topology", run: checkCiTopology },
|
||||
{ name: "craft references", run: checkCraftReferences },
|
||||
{ name: "plugin preview manifest", run: checkPluginPreviewManifest },
|
||||
{ name: "design system manifests", run: checkDesignSystemManifests },
|
||||
@@ -1376,9 +1325,7 @@ const checks: GuardCheck[] = [
|
||||
|
||||
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
||||
if (isMain) {
|
||||
// `--list-checks` is the machine-readable registry of guard check names; the
|
||||
// scope rule-table invariant test resolves `certain` rules' guard fields
|
||||
// against it so a renamed or deleted guard fails CI.
|
||||
// `--list-checks` is the machine-readable registry of repository guard checks.
|
||||
if (process.argv[2] === "--list-checks") {
|
||||
for (const check of checks) console.log(check.name);
|
||||
} else if (!(await runGuardChecks(checks, { repoRoot }))) {
|
||||
|
||||
@@ -4,12 +4,8 @@ import ts from "typescript";
|
||||
|
||||
import type { GuardContext } from "./core.ts";
|
||||
|
||||
const suiteModule = "e2e/lib/playwright/suites.ts";
|
||||
const scopesModule = "scripts/scopes.ts";
|
||||
const guardModule = "scripts/guard.ts";
|
||||
const guardLibraryPrefix = "scripts/lib/guard/";
|
||||
const scopePolicyModule = `${guardLibraryPrefix}scope.ts`;
|
||||
const scopeLibraryPrefix = "scripts/lib/scope/";
|
||||
|
||||
function repositoryPath(filePath: string): string {
|
||||
return filePath.split(path.sep).join("/");
|
||||
@@ -35,9 +31,7 @@ async function collectTypeScriptSources(
|
||||
}
|
||||
|
||||
export async function loadScriptsArchitectureSources(repoRoot: string): Promise<Map<string, string>> {
|
||||
const sources = await collectTypeScriptSources(repoRoot);
|
||||
sources.set(suiteModule, await readFile(path.join(repoRoot, suiteModule), "utf8"));
|
||||
return sources;
|
||||
return collectTypeScriptSources(repoRoot);
|
||||
}
|
||||
|
||||
function importsFrom(source: string): string[] {
|
||||
@@ -129,12 +123,7 @@ export function scriptsArchitectureErrors(sources: ReadonlyMap<string, string>):
|
||||
if (dependency != null && specifier.startsWith(".") && !sources.has(dependency)) {
|
||||
errors.push(`${module} imports missing module ${dependency}`);
|
||||
}
|
||||
if (
|
||||
dependency == null ||
|
||||
(!dependency.startsWith(guardLibraryPrefix) &&
|
||||
(module !== scopePolicyModule ||
|
||||
(dependency !== scopesModule && dependency !== suiteModule)))
|
||||
) {
|
||||
if (dependency == null || !dependency.startsWith(guardLibraryPrefix)) {
|
||||
errors.push(`${module} imports ${specifier} outside the guard library closure`);
|
||||
}
|
||||
if (dependency === guardModule) {
|
||||
@@ -148,33 +137,8 @@ export function scriptsArchitectureErrors(sources: ReadonlyMap<string, string>):
|
||||
}
|
||||
}
|
||||
|
||||
const scopeClosure = new Set<string>();
|
||||
const visitScope = (module: string): void => {
|
||||
if (scopeClosure.has(module)) return;
|
||||
scopeClosure.add(module);
|
||||
const source = sources.get(module);
|
||||
if (source == null) {
|
||||
errors.push(`${module} is missing from the preinstall scope closure`);
|
||||
return;
|
||||
}
|
||||
for (const specifier of importsFrom(source)) {
|
||||
if (specifier.startsWith("node:")) continue;
|
||||
const dependency = resolveImport(module, specifier, sources);
|
||||
if (
|
||||
dependency == null ||
|
||||
(dependency !== suiteModule && !dependency.startsWith(scopeLibraryPrefix))
|
||||
) {
|
||||
errors.push(`${module} imports ${specifier} outside the install-independent scope closure`);
|
||||
continue;
|
||||
}
|
||||
visitScope(dependency);
|
||||
}
|
||||
};
|
||||
visitScope(scopesModule);
|
||||
|
||||
errors.push(
|
||||
...cycleErrors(graph, [
|
||||
scopesModule,
|
||||
...[...sources.keys()].filter((module) => module.startsWith("scripts/lib/")),
|
||||
]),
|
||||
);
|
||||
@@ -188,6 +152,6 @@ export async function checkScriptsLibraryArchitecture(context: GuardContext): Pr
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
return false;
|
||||
}
|
||||
console.log("Scripts library architecture check passed: scope startup and guard internals stay layered.");
|
||||
console.log("Scripts library architecture check passed: workflow control and guard internals stay layered.");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { uiP0CiMatrix } from "../../../e2e/lib/playwright/suites.ts";
|
||||
import {
|
||||
CERTAIN_DAEMON_CORE_EXCLUDED_EXACT,
|
||||
CERTAIN_DAEMON_CORE_EXCLUDED_PREFIXES,
|
||||
CERTAIN_DAEMON_CORE_PREFIXES,
|
||||
DAEMON_RUNTIME_DEFINITION_EXACT,
|
||||
DAEMON_RUNTIME_DEFINITION_PREFIXES,
|
||||
evaluateScopeOutputs,
|
||||
evaluateUiP0Shadow,
|
||||
matchesRuleMatch,
|
||||
scopeRules,
|
||||
} from "../../scopes.ts";
|
||||
import type { GuardContext } from "./core.ts";
|
||||
|
||||
const fullMatrixNames = [
|
||||
"entry-settings",
|
||||
"project-workspace",
|
||||
"project-workspace-editor",
|
||||
"project-collab",
|
||||
"project-runtime",
|
||||
"workspace-restoration",
|
||||
] as const;
|
||||
const candidateMatrixNames = [
|
||||
"entry-settings",
|
||||
"project-workspace",
|
||||
"project-collab",
|
||||
"project-runtime",
|
||||
] as const;
|
||||
|
||||
function matrixNames(matrix: readonly { name: string }[]): string[] {
|
||||
return matrix.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
function sameValues(actual: readonly string[], expected: readonly string[]): boolean {
|
||||
return actual.length === expected.length && actual.every((value, index) => value === expected[index]);
|
||||
}
|
||||
|
||||
export function uiP0ShadowContractErrors(): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!sameValues(matrixNames(uiP0CiMatrix), fullMatrixNames)) {
|
||||
errors.push("the applied UI P0 matrix is no longer the guarded full six-domain matrix");
|
||||
}
|
||||
|
||||
const sourceSample = `${DAEMON_RUNTIME_DEFINITION_PREFIXES[0]}example.ts`;
|
||||
const testSample = DAEMON_RUNTIME_DEFINITION_EXACT.find((file) => file.includes("/tests/"));
|
||||
const candidate = evaluateUiP0Shadow(testSample == null ? [sourceSample] : [sourceSample, testSample]);
|
||||
if (
|
||||
candidate.mode !== "candidate" ||
|
||||
candidate.capability !== "daemon-runtime-definition" ||
|
||||
!sameValues(matrixNames(candidate.matrix), candidateMatrixNames)
|
||||
) {
|
||||
errors.push("the runtime-definition shadow no longer resolves to the guarded four-domain candidate");
|
||||
}
|
||||
|
||||
for (const outsideFile of [
|
||||
"apps/daemon/src/server.ts",
|
||||
"apps/daemon/src/runtimes/detection.ts",
|
||||
"apps/web/src/App.tsx",
|
||||
]) {
|
||||
const fallback = evaluateUiP0Shadow([sourceSample, outsideFile]);
|
||||
if (
|
||||
fallback.mode !== "full-fallback" ||
|
||||
fallback.reason !== "outside-capability" ||
|
||||
!sameValues(matrixNames(fallback.matrix), fullMatrixNames)
|
||||
) {
|
||||
errors.push(`${outsideFile} no longer forces the runtime-definition shadow to the full matrix`);
|
||||
}
|
||||
}
|
||||
|
||||
const unresolved = evaluateUiP0Shadow([], false);
|
||||
if (
|
||||
unresolved.mode !== "full-fallback" ||
|
||||
unresolved.reason !== "files-unresolved" ||
|
||||
!sameValues(matrixNames(unresolved.matrix), fullMatrixNames)
|
||||
) {
|
||||
errors.push("unresolved changed files no longer force the UI P0 shadow to the full matrix");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function checkUiP0ShadowContract(): Promise<boolean> {
|
||||
const errors = uiP0ShadowContractErrors();
|
||||
if (errors.length > 0) {
|
||||
console.error("UI P0 shadow-contract violations found:");
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
return false;
|
||||
}
|
||||
console.log("UI P0 shadow contract check passed: applied coverage stays full and fallbacks stay closed.");
|
||||
return true;
|
||||
}
|
||||
|
||||
const daemonCoreRuleId = "certain-daemon-core";
|
||||
const daemonCoreEffects = [
|
||||
"daemon_tests_required",
|
||||
"ui_critical_validation_required",
|
||||
"ui_p0_validation_required",
|
||||
"workspace_validation_required",
|
||||
] as const;
|
||||
|
||||
function matchingRuleIds(file: string): string[] {
|
||||
return scopeRules.filter((rule) => matchesRuleMatch(file, rule.match)).map((rule) => rule.id);
|
||||
}
|
||||
|
||||
export function daemonCoreScopeContractErrors(): string[] {
|
||||
const errors: string[] = [];
|
||||
const samples = [
|
||||
`${CERTAIN_DAEMON_CORE_PREFIXES[0]}server.ts`,
|
||||
`${CERTAIN_DAEMON_CORE_PREFIXES[0]}policy.md`,
|
||||
`${CERTAIN_DAEMON_CORE_PREFIXES[1]}server.test.ts`,
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const matched = matchingRuleIds(sample);
|
||||
if (!sameValues(matched, [daemonCoreRuleId])) {
|
||||
errors.push(`${sample} resolves to ${matched.join(", ") || "no rules"} instead of only ${daemonCoreRuleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const evaluation = evaluateScopeOutputs(samples, "certain", {
|
||||
deriveWorkspaceValidationFromTestScopes: true,
|
||||
});
|
||||
const enabledEffects = Object.entries(evaluation.outputs)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([effect]) => effect);
|
||||
if (!sameValues(enabledEffects, daemonCoreEffects)) {
|
||||
errors.push(`daemon core effects changed from ${daemonCoreEffects.join(", ")} to ${enabledEffects.join(", ")}`);
|
||||
}
|
||||
if (evaluation.decisions.some((decision) => decision.escalated)) {
|
||||
errors.push("daemon core samples no longer resolve without certain-tier escalation");
|
||||
}
|
||||
|
||||
for (const outsideFile of [
|
||||
`${CERTAIN_DAEMON_CORE_EXCLUDED_PREFIXES[0]}server.ts`,
|
||||
`${DAEMON_RUNTIME_DEFINITION_PREFIXES[0]}example.ts`,
|
||||
CERTAIN_DAEMON_CORE_EXCLUDED_EXACT[0],
|
||||
"apps/daemon/package.json",
|
||||
]) {
|
||||
const outside = evaluateScopeOutputs([outsideFile], "certain", {
|
||||
deriveWorkspaceValidationFromTestScopes: true,
|
||||
});
|
||||
if (!outside.decisions[0]?.escalated || matchingRuleIds(outsideFile).includes(daemonCoreRuleId)) {
|
||||
errors.push(`${outsideFile} no longer stays outside the certain daemon core`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function webDaemonFilesystemConsumers(repoRoot: string): Promise<string[]> {
|
||||
const consumers: string[] = [];
|
||||
const root = path.join(repoRoot, "apps/web/tests");
|
||||
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(fullPath);
|
||||
} else if (entry.isFile() && /\.(?:ts|tsx)$/.test(entry.name)) {
|
||||
const source = await readFile(fullPath, "utf8");
|
||||
const usesFileSystem = /from\s+["']node:fs(?:\/promises)?["']/.test(source);
|
||||
const namesDaemonTree =
|
||||
source.includes("apps/daemon/") ||
|
||||
/["']apps["']\s*,\s*["']daemon["']/.test(source);
|
||||
if (usesFileSystem && namesDaemonTree) {
|
||||
consumers.push(path.relative(repoRoot, fullPath).split(path.sep).join("/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await visit(root);
|
||||
return consumers.sort();
|
||||
}
|
||||
|
||||
async function daemonCoreRepositoryContractErrors(repoRoot: string): Promise<string[]> {
|
||||
const errors = daemonCoreScopeContractErrors();
|
||||
const visualHarness = await readFile(path.join(repoRoot, "e2e/lib/playwright/visual.ts"), "utf8");
|
||||
const ciWorkflow = await readFile(path.join(repoRoot, ".github/workflows/ci.yml"), "utf8");
|
||||
|
||||
for (const pattern of ["**/api/**", "**/artifacts/**", "**/frames/**", "**/powered/**"]) {
|
||||
if (!visualHarness.includes(pattern)) {
|
||||
errors.push(`visual harness no longer intercepts daemon route ${pattern}`);
|
||||
}
|
||||
}
|
||||
if (!visualHarness.includes("not mocked by visual coverage") || visualHarness.includes("route.continue()")) {
|
||||
errors.push("visual harness no longer terminates unmocked daemon routes at its browser boundary");
|
||||
}
|
||||
|
||||
const obsoleteWebWalker = path.join(
|
||||
repoRoot,
|
||||
"apps/web/tests/components/Theater/critique-coverage.test.ts",
|
||||
);
|
||||
try {
|
||||
await access(obsoleteWebWalker);
|
||||
errors.push("obsolete web-owned critique walker still consumes the daemon tree");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
// The authoritative cross-app walker lives in e2e/tests/critique-coverage.test.ts.
|
||||
}
|
||||
for (const consumer of await webDaemonFilesystemConsumers(repoRoot)) {
|
||||
errors.push(`${consumer} reads the daemon tree from the web test lane`);
|
||||
}
|
||||
|
||||
for (const needle of [
|
||||
"needs.scopes.outputs.run_e2e_vitest == 'true'",
|
||||
"needs.scopes.outputs.run_ui_p0 == 'true'",
|
||||
"pnpm --filter @open-design/e2e test",
|
||||
"include: ${{ fromJSON(needs.scopes.outputs.ui_p0_matrix) }}",
|
||||
]) {
|
||||
if (!ciWorkflow.includes(needle)) {
|
||||
errors.push(`CI workflow no longer preserves daemon-core coverage: missing ${needle}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function checkDaemonCoreBoundary(context: GuardContext): Promise<boolean> {
|
||||
const errors = await daemonCoreRepositoryContractErrors(context.repoRoot);
|
||||
if (errors.length > 0) {
|
||||
console.error("Daemon core boundary violations found:");
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
return false;
|
||||
}
|
||||
console.log("Daemon core boundary check passed: retained behavior lanes and skipped consumers stay isolated.");
|
||||
return true;
|
||||
}
|
||||
-1057
File diff suppressed because it is too large
Load Diff
+83
-108
@@ -1,7 +1,7 @@
|
||||
# CI scope confidence methodology
|
||||
|
||||
This is the current authority for CI scope confidence rules in
|
||||
`scripts/scopes.ts`, their guard requirements, and their evidence recipes.
|
||||
`.github/config/scopes.json`, their planner invariants, and their evidence recipes.
|
||||
Workflow topology and the capability/handoff architecture stay owned by
|
||||
`.github/AGENTS.md`; do not restate them here.
|
||||
|
||||
@@ -14,7 +14,7 @@ that change history.
|
||||
## The model in three paragraphs
|
||||
|
||||
Every changed file is classified by the additive rule table in
|
||||
`scripts/scopes.ts`: effects union across matched rules, confidence is the
|
||||
`.github/config/scopes.json`: effects union across matched rules, confidence is the
|
||||
minimum across matched rules. Each evaluation context brings a trust threshold:
|
||||
PR and manual-hot runs believe `medium`, the merge queue believes only
|
||||
`certain`, manual-full runs believe nothing. Renames contribute both the
|
||||
@@ -22,19 +22,50 @@ current and previous filename so moving a file cannot discard the source
|
||||
path's validation effects. A file below threshold — or
|
||||
matching no rule — escalates fail-closed to the full radius.
|
||||
|
||||
The policy floor never moves: `run_preflight` is true in every plan, and its
|
||||
workspace setup, `pnpm guard`, and i18n structure check always execute. Broad
|
||||
The scope policy floor never moves: `preflight` is enabled in every scope plan.
|
||||
Its current `"*"` hash declaration makes workspace setup, `pnpm guard`, and
|
||||
i18n structure checks execute for every new tracked tree, while an identical
|
||||
cached invocation may skip the whole job. Broad
|
||||
app declaration builds, workspace typecheck, and `run_workspace_unit_tests`
|
||||
may skip only for a merge-queue plan whose certain-tier evaluation claims zero
|
||||
validation effects. PR, manual-hot, forced-full, and escalated queue plans keep
|
||||
all broad workspace validation.
|
||||
|
||||
`scripts/scopes.ts` remains an install-independent preinstall entrypoint.
|
||||
`scripts/guard.ts` is the postinstall policy-floor entrypoint and composes its
|
||||
shared mechanism and scope contracts from `scripts/lib/guard/`. The
|
||||
`scripts library architecture` guard keeps those layers acyclic, prevents
|
||||
scope startup from reaching guard or third-party dependencies, and keeps CLI
|
||||
process control out of the library closure.
|
||||
`.github/scripts/scopes.py` is the install-independent, Linux workflow-control entrypoint.
|
||||
Its rule and matrix data lives in `.github/config/scopes.json`; it never imports
|
||||
workspace code. `.github/scripts/runners.py` and `.github/scripts/hash.py` share
|
||||
the same stdlib-only cold-start boundary.
|
||||
`scripts/guard.ts` is a downstream repository-policy entrypoint. It runs only
|
||||
after the plan exists and therefore does not authorize scope classification or
|
||||
workload omission. The planner validates its own configuration and routing
|
||||
contract before emitting any workload decision; repository guards remain
|
||||
useful checks, but they are not part of the planner's trust chain.
|
||||
|
||||
## Orthogonal hash composition
|
||||
|
||||
The scope planner answers whether a workload is relevant to the changed-file
|
||||
context. The hash register answers whether that workload's declared Git input
|
||||
combination differs from the previous invocation on the branch. CI runs a
|
||||
workload only when `scope_enabled && !hash_equal`; fine-grained commands inside
|
||||
the workload remain a separate business-layer concern.
|
||||
|
||||
Declarations live in `.github/config/hash.json`. A declaration may contain Git
|
||||
paths/globs, `suite://<name>` reusable path groups, `key://<workflow>/<identity>`
|
||||
dependencies, or `"*"` for the entire tracked tree. Cycles, dangling references,
|
||||
unsafe paths, empty matches, schema drift, and scope/hash identity drift fail at
|
||||
the Linux plan entrypoint before workload dispatch. The initial contract uses
|
||||
`"*"` for every identity; narrow closures require high-confidence evidence and
|
||||
may be introduced independently later.
|
||||
|
||||
Actions cache stores only the previous identity-to-hash map. `hash.py` reads it,
|
||||
computes and compares every current identity, then atomically replaces the local
|
||||
state before workloads run. The plan transfers that pending map to `validate`,
|
||||
which publishes it to Actions cache only after the gate succeeds. The map carries
|
||||
no job-success, retry, or reliability meaning; success controls publication, not
|
||||
payload. Restore, transfer, and save failures are non-fatal and therefore start
|
||||
cold; invalid configuration is fatal. Only
|
||||
`if: ${{ fromJSON(needs.plan.outputs.run).<identity> }}` in `ci.yml` turns the
|
||||
static comparison into a skip.
|
||||
|
||||
The error cost is asymmetric by tier. A wrong `medium` rule under-arms a PR
|
||||
run and gets caught by the merge queue's stricter threshold — cost: one queue
|
||||
@@ -53,65 +84,37 @@ frequency-weighted tonnage lists barely intersect).
|
||||
|
||||
## Certain-tier requirements
|
||||
|
||||
A PR that makes a rule `certain` must be statable in three sentences: which
|
||||
rule, what guard, how much tonnage. Anything that cannot fit that statement is
|
||||
riding along and must be split out.
|
||||
`certain` is an operational planner policy, not a proof that semantic
|
||||
dependencies are complete. A downstream job, including `pnpm guard`, cannot
|
||||
authorize an omission already made by the plan that scheduled it.
|
||||
|
||||
Requirements:
|
||||
|
||||
1. **A defensible core.** Promote the subset of the surface whose boundary
|
||||
invariant is local and checkable. Split the rule if needed. Example: the
|
||||
global `*.md` regex is permanently medium because its safety depends on
|
||||
*other* rules covering every runtime-markdown directory — a cross-rule
|
||||
invariant no local guard can keep.
|
||||
2. **A guard that resolves.** The rule's `guard` field must name a live
|
||||
`scripts/guard.ts` check (`pnpm --silent guard --list-checks` is the
|
||||
registry; the rule-table invariant test enforces resolution). Guards for
|
||||
certain rules must run in the policy floor — `pnpm guard` in preflight
|
||||
qualifies — so the check that justifies skipping always itself runs.
|
||||
3. **Evidence proportional to the guard's strength.** Guard invariants come in
|
||||
three strengths: *definitional* (the surface cannot enter build or runtime
|
||||
by construction — e.g. docs), *structural* (an import-graph boundary), and
|
||||
*behavioral* (a topology test). Definitional rules may rely on replay
|
||||
evidence alone. Structural and behavioral rules additionally require at
|
||||
least 10 qualifying single-PR queue groups from the latest 400 first-parent
|
||||
merges. Native `ifTrustAll` traces are preferred; paired evidence also
|
||||
qualifies when the PR ran the candidate medium plan for the same file set,
|
||||
the real queue group ran the full plan, both succeeded, and the proposed
|
||||
plan has not weakened since that pair.
|
||||
4. **Goldens updated, divergence pinned.** The golden that changes is the
|
||||
proof of the behavior change; the goldens that do not change are the proof
|
||||
of its containment.
|
||||
5. **Exceptions bind to checkable preconditions.** Every guard allowlist entry
|
||||
is a claim, and claims split by what justifies them. A *local, definitional*
|
||||
fact ("this string is passed as data to a pure function, never opened") may
|
||||
stay prose — it can only be falsified by editing the allowlisted file
|
||||
itself, which puts the entry in front of a reviewer. A *remote, mutable*
|
||||
fact ("that lane doesn't run this file", "that workflow is outside the
|
||||
gate") must not be trusted as prose: the guard verifies the fact and drops
|
||||
the exception the moment it stops holding, so the failure mode is a loud
|
||||
guard report at the change that broke the premise — not a rationale that
|
||||
rotted silently years earlier. Worked example: the consumption guard
|
||||
tolerates `apps/daemon/tests/runtimes/trae-cli.test.ts` reading
|
||||
`docs/agent-adapters.md` because that exact document is classified as
|
||||
daemon core. Editing the consumed document therefore runs the same full
|
||||
daemon suite as editing its consumer; the allowlist cannot create a skipped
|
||||
producer/consumer edge.
|
||||
1. **A conservative rule-table boundary.** Keep promoted matches explicit and
|
||||
narrow. Unknown, mixed, empty-unresolved, invalid, or below-threshold inputs
|
||||
must select the full plan.
|
||||
2. **Planner-owned validation.** `python3 .github/scripts/scopes.py validate`
|
||||
must reject schema drift, unknown effects, invalid regexes, match cycles,
|
||||
malformed or duplicate matrices, and invalid UI P0 shadow references before
|
||||
any workload decision is emitted.
|
||||
3. **Direct planner behavior tests.** Goldens invoke `scopes.py plan` itself for
|
||||
representative in-bound, out-of-bound, mixed, and fallback inputs. Do not
|
||||
reimplement the evaluator in another language and compare two copies.
|
||||
4. **Measured operational evidence.** Replay and paired-run evidence quantify
|
||||
how often a rule applies and whether the retained plan has passed in
|
||||
practice. This evidence can justify an operational decision, but it must not
|
||||
be described as a complete dependency proof.
|
||||
|
||||
No general demotion policy is defined. One hard rule is active: if a guard
|
||||
check is deleted or renamed, the rule-table invariant test fails CI — a
|
||||
certain rule can never silently outlive its guard. Rule five is the same
|
||||
principle one level down: an exception can never silently outlive its premise.
|
||||
Independent semantic-closure guards may be evaluated later. They must sit
|
||||
outside the planner's scheduling authority before their evidence can strengthen
|
||||
a `certain` claim.
|
||||
|
||||
## Certain-exempt boundary
|
||||
|
||||
Rule `certain-exempt-surface`: prefixes `docs/`, `apps/landing-page/`,
|
||||
`.vscode/`, `.idea/`, `.github/ISSUE_TEMPLATE/` plus exacts `LICENSE`,
|
||||
`.github/CODEOWNERS`. Guard: `certain-exempt surface consumption`
|
||||
(`scripts/check-certain-exempt-consumption.ts`) — no skippable-lane source may
|
||||
reference a certain-exempt path; policy-floor code (root `scripts/`) is exempt
|
||||
from the scan because preflight always runs and may validate docs content
|
||||
(product neutrality does).
|
||||
`.github/CODEOWNERS`. The planner owns this classification directly; no
|
||||
downstream guard is treated as proof that these files are unconsumed.
|
||||
|
||||
Current evidence and exceptions:
|
||||
|
||||
@@ -120,10 +123,6 @@ Current evidence and exceptions:
|
||||
- Root markdown such as `README.md` remains medium because bare filename
|
||||
literals are widespread as project-fixture data and are not locally
|
||||
distinguishable from repository-root reads.
|
||||
- Allowlisted true consumer:
|
||||
`tools/release/src/release-note/prepare.ts` reads `docs/CHANGELOG`, which
|
||||
executes only in release workflows; `@open-design/tools-release` tests run
|
||||
in no `ci.yml` lane.
|
||||
|
||||
## Certain packaged-leaf boundary
|
||||
|
||||
@@ -140,23 +139,10 @@ the focused packaged launcher update-loop fallback, and Windows launcher
|
||||
payload tests. It skips web workspace tests, broad E2E Vitest, UI P0, critical
|
||||
Playwright, and visual Playwright.
|
||||
|
||||
Guard: `packaged leaf boundary`
|
||||
(`scripts/check-packaged-leaf-boundary.ts`). The policy-floor check scans
|
||||
skippable-lane source for package imports and repository paths entering the
|
||||
certain core, verifies that every core sample resolves to exactly the guarded
|
||||
effects at the certain threshold, and pins the workspace-unit command block.
|
||||
Allowed consumers are limited to:
|
||||
|
||||
- `tools/dev/`, whose tests stay armed by the certain rule;
|
||||
- the focused packaged launcher update-loop test;
|
||||
- scope, workflow, cross-app, fork-approval, and package-manager invocation
|
||||
fixtures that treat the paths as data;
|
||||
- the packaged and tools-pack esbuild entry configs, which own their source
|
||||
entrypoints while config changes themselves remain medium-tier.
|
||||
|
||||
Package manifests, build configs, bins, vendor content, and files outside the
|
||||
listed core remain medium. A mixed queue group containing any medium file
|
||||
still escalates to the full plan.
|
||||
still escalates to the full plan. Direct `scopes.py plan` tests pin the retained
|
||||
effects and escalation behavior; they do not claim to prove every consumer.
|
||||
|
||||
Current evidence:
|
||||
|
||||
@@ -186,19 +172,10 @@ to exercise daemon buildability, user-level API/runtime behavior, and every
|
||||
merge-gated UI P0 capability without treating web-owned rendering tests or
|
||||
packaging-format tests as daemon consumers.
|
||||
|
||||
Guard: `daemon core boundary` (`scripts/lib/guard/scope.ts`). The policy-floor
|
||||
check verifies that:
|
||||
|
||||
- representative source, markdown, and test files resolve only to the certain
|
||||
daemon rule and its exact guarded effects;
|
||||
- the daemon sidecar subtree, runtime-definition shadow, and daemon package
|
||||
manifest still escalate;
|
||||
- the workflow continues to execute E2E Vitest and the full UI P0 matrix;
|
||||
- web code cannot import another app's private implementation, and web tests
|
||||
do not read the daemon tree through filesystem APIs;
|
||||
- the visual harness intercepts every daemon-owned route family; explicit
|
||||
visual fixtures win and every remaining request terminates with a
|
||||
deterministic browser-side 404.
|
||||
Direct `scopes.py plan` tests pin representative daemon-core routing and
|
||||
out-of-bound escalation. General cross-app and visual-harness guards remain
|
||||
repository checks, but they do not authorize the planner's daemon-core
|
||||
omissions.
|
||||
|
||||
The authoritative cross-app critique coverage walker lives in
|
||||
`e2e/tests/critique-coverage.test.ts`, which remains armed by the daemon-core
|
||||
@@ -206,7 +183,7 @@ plan. The latest 400 first-parent merges contain 78 pure daemon-core groups.
|
||||
Fifteen recent groups have successful narrow PR validation paired with
|
||||
successful full merge-group validation. A representative full queue run spends
|
||||
about 20 runner-minutes in the web, visual, and Windows jobs omitted by the
|
||||
guarded plan; UI P0 remains the critical path.
|
||||
planner; UI P0 remains the critical path.
|
||||
|
||||
## Daemon UI P0 capability shadow
|
||||
|
||||
@@ -220,7 +197,7 @@ The `daemon-runtime-definition` capability matches changes confined to:
|
||||
- `capabilities.ts`, `local-profiles.ts`, `metadata.ts`, and `registry.ts`
|
||||
directly under `apps/daemon/src/runtimes/`;
|
||||
- the explicit companion-test list in
|
||||
`DAEMON_RUNTIME_DEFINITION_EXACT` (`scripts/scopes.ts`).
|
||||
the `daemon-runtime-definition` exact list (`.github/config/scopes.json`).
|
||||
|
||||
Its candidate keeps `entry-settings`, `project-workspace`, and
|
||||
`project-runtime`, and omits only `workspace-restoration`. The project
|
||||
@@ -229,11 +206,9 @@ and model selector. Any empty, unresolved, mixed, unknown, or out-of-surface
|
||||
change falls back to the full four-domain matrix and records the reason in
|
||||
`trace.uiP0Shadow`.
|
||||
|
||||
Guard: `UI P0 shadow contract` (`scripts/lib/guard/scope.ts`). It pins the
|
||||
applied full matrix, the candidate group set, representative in-bound
|
||||
resolution, and full fallback for shared daemon, runtime-composition, web, and
|
||||
unresolved inputs. The shadow must accumulate successful paired runs before it
|
||||
can become an execution input under the certain-tier requirements.
|
||||
Direct `scopes.py plan` tests pin the applied full matrix, candidate group set,
|
||||
representative in-bound resolution, and full fallback. The shadow must
|
||||
accumulate successful paired runs before it can become an execution input.
|
||||
|
||||
The latest-400 first-parent replay contains three matching groups. The
|
||||
candidate would avoid one UI P0 worker per matching group, currently about
|
||||
@@ -249,11 +224,9 @@ The predicate is queue-only: PR/manual-hot run broad validation even when the
|
||||
medium-tier plan has no effects, and forced-full or escalated queue plans run
|
||||
everything.
|
||||
|
||||
The certain-exempt consumption guard executes in preflight, and `pnpm guard`
|
||||
sees every changed path (including a misleading executable such as
|
||||
`docs/example.js`). The workspace-unit job does not own landing-page
|
||||
validation, and the broad workspace typecheck excludes
|
||||
`@open-design/landing-page`.
|
||||
`pnpm guard` still runs as ordinary policy-floor work when preflight is
|
||||
enabled, but its result does not authorize the zero-effect plan. The planner's
|
||||
classification and fail-open behavior are the operative contract.
|
||||
|
||||
The 398-merge replay ending at `b99a9fdc3` contains 46 qualifying queue plans
|
||||
(11.6%). A sample of 12 successful merge-group runs measures broad prebuild
|
||||
@@ -265,7 +238,7 @@ window).
|
||||
## Evidence recipes
|
||||
|
||||
Design rule: shell only fetches file lists and extracts logs; every scope
|
||||
judgment goes through `scripts/scopes.ts plan`. Never reimplement rule
|
||||
judgment goes through `.github/scripts/scopes.py plan`. Never reimplement rule
|
||||
semantics in a pipeline.
|
||||
|
||||
Replay recent merges through the evaluator (candidate tonnage):
|
||||
@@ -273,7 +246,7 @@ Replay recent merges through the evaluator (candidate tonnage):
|
||||
```bash
|
||||
git log --first-parent -400 --pretty=%H origin/main | while read -r sha; do
|
||||
git diff-tree -r --name-only --no-commit-id "$sha^" "$sha" |
|
||||
node --experimental-strip-types scripts/scopes.ts plan \
|
||||
python3 .github/scripts/scopes.py plan \
|
||||
--context merge-queue --files-from - |
|
||||
node -e 'const d=JSON.parse(require("fs").readFileSync(0,"utf8"));
|
||||
console.log(d.trace.escalations.length === 0 ? "PURE" : "ESCALATED")'
|
||||
@@ -283,7 +256,7 @@ done | sort | uniq -c
|
||||
Classify one change set offline (PR-side view, prints `{ plan, trace }`):
|
||||
|
||||
```bash
|
||||
node --experimental-strip-types scripts/scopes.ts plan --context pr \
|
||||
python3 .github/scripts/scopes.py plan --context pr \
|
||||
--files apps/web/src/App.tsx docs/architecture.md
|
||||
```
|
||||
|
||||
@@ -307,7 +280,9 @@ infrastructure.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Demotion policy beyond the guard-resolution hard rule.
|
||||
- A demotion policy for `certain` rules when planner evidence becomes stale.
|
||||
- What independent evidence source could strengthen semantic closure without
|
||||
being scheduled by the plan it is meant to assess.
|
||||
- Whether medium-tier zero-effect PR plans should use the policy floor; this
|
||||
needs its own evidence and containment review.
|
||||
- Queue batching discount: the 11.6% figure assumes single-PR queue groups; a
|
||||
|
||||
Reference in New Issue
Block a user