diff --git a/.github/workflows/devkit-ci.yml b/.github/workflows/devkit-ci.yml index 2572d16..5cae631 100644 --- a/.github/workflows/devkit-ci.yml +++ b/.github/workflows/devkit-ci.yml @@ -90,6 +90,7 @@ jobs: self-test-commands: | pip install "pyyaml==6.0.3" bash tests/self-tests.sh + python3 scripts/wiring.py --lint-devkit static: if: github.event_name == 'pull_request' diff --git a/CHANGELOG.md b/CHANGELOG.md index cdabd3b..e270c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Each release moves `plugin.json`'s `version`, the flake package version and ever together. A consumer adopts one by bumping its flake input, its workflow `@v…`/`devkit-ref` and its marketplace `ref` at once. +## Unreleased + +- `wiring.py` reports a `/dispatches` call whose type it cannot read, and `--check` fails on it; `wiring.py --lint-devkit` fails on that, or on a `repository_dispatch` type no devkit workflow sends, in the devkit's own workflows, and devkit CI runs it on every PR (#163). + ## v0.15.1 — 2026-10-17 - `notify-devkit.yml` takes the dispatch type from an `EVENT_TYPE` env key and `notify-devkit-test.yml` calls it by its tagged path, so `wiring.py` lists the devkit as a `bump-ready` sender and the umbrella's `wiring --check` stops failing on it (#161). diff --git a/docs/4-reference_tools.md b/docs/4-reference_tools.md index aa227f3..9e00858 100644 --- a/docs/4-reference_tools.md +++ b/docs/4-reference_tools.md @@ -58,7 +58,7 @@ extension rather than a devkit tool. | `merge-key-check` | `merge_key_check.py` | `just quality` | Fails when `MERGE_BOT_KEY` appears in a workflow outside the allowlist `.github/merge-key-allowlist` (workflow files, one repo-relative path per line, `#` comments), or in a job that runs third-party actions or fetch/build commands, calls a reusable workflow (except a listed local one), sets `container`/`services` or a shell other than bash or sh, or sits beside workflow-level env carrying the key (MIP-0086 §5.6). Once the allowlist exists, `secrets: inherit` is flagged repo-wide | | `agents-check` | `agents-check.sh` | `just agents-check` | Compare AGENTS.md's invariants block byte for byte with the pinned devkit's `agents/invariants.md` (`--block` or `MAROLA_INVARIANTS_BLOCK` to use another) | | `docs-lint` | `docs_lint.py` | (none) | MIP-0074 §7's stale-content check over `README.md` and `docs/**/*.md`: undefined or unmarked foreign recipes, another repo's paths, split-era wording, `docs/index.md`, links leaving the repo | -| `wiring` | `wiring.py` | `just wiring` | MIP-0076 §5.1's four tables (artifact, dispatch, pin bump, deploy), parsed from the umbrella's and every submodule's workflows, pin files, scripts, justfiles, Dockerfiles, compose files and `build.sbt`, with each devkit workflow call's ref (each call is read at that ref: from a local devkit clone's tags, else fetched from GitHub, a tag cached under `~/.cache/marola-wiring`). `wiring [FILE]` prints the block or rewrites it between FILE's `wiring:start`/`wiring:end` markers; `--check [FILE]` fails on a stale block, a dispatch sent or listened for on one side only, or an artifact nobody reads that the umbrella's `wiring.allow` (`: ` per line) does not list; run it in an umbrella checkout with submodules | +| `wiring` | `wiring.py` | `just wiring` | MIP-0076 §5.1's four tables (artifact, dispatch, pin bump, deploy), parsed from the umbrella's and every submodule's workflows, pin files, scripts, justfiles, Dockerfiles, compose files and `build.sbt`, with each devkit workflow call's ref (each call is read at that ref: from a local devkit clone's tags, else fetched from GitHub, a tag cached under `~/.cache/marola-wiring`). `wiring [FILE]` prints the block or rewrites it between FILE's `wiring:start`/`wiring:end` markers; `--check [FILE]` fails on a stale block, an orphan or unreadable dispatch, or an artifact nobody reads that the umbrella's `wiring.allow` (`: ` per line) does not list; run it in an umbrella checkout with submodules. `--lint-devkit [DIR]` fails on the devkit's own workflows: a dispatch it cannot read, or a type they listen for and none sends (devkit CI runs it) | | `workflow-runners` | `workflow_runners.py` | (none) | Fail when any workflow but the GPU publish one can reach a self-hosted runner ([runners](4-reference_runners.md)) | | `skills-vendor` | `skills_vendor.py` | `just skills-check`, `just skills-outdated`, `just skills-update` | Pin vendored skills to an upstream commit in `skills.lock` (below) | diff --git a/justfile b/justfile index bca1ffd..e5560ce 100644 --- a/justfile +++ b/justfile @@ -20,6 +20,7 @@ quality: shellcheck --severity=error scripts/*.sh scripts/lib/*.sh plugins/marola-devkit/hooks/*.sh .githooks/* .claude/statusline.sh tests/*.sh actionlint bash tests/self-tests.sh + python3 scripts/wiring.py --lint-devkit scripts/agents-check.sh python3 scripts/merge_key_check.py . python3 scripts/docs_lint.py diff --git a/scripts/wiring.py b/scripts/wiring.py index 1b084d7..68180af 100755 --- a/scripts/wiring.py +++ b/scripts/wiring.py @@ -8,8 +8,11 @@ lets each reusable-workflow call be read at its own @ref, else it is fetched from GitHub (a tag is cached under ~/.cache/marola-wiring) wiring --check [FILE] - fail on a stale block in FILE, an orphan dispatch, or an artifact nobody reads that + fail on a stale block in FILE, an orphan or unreadable dispatch, or an artifact nobody reads that /wiring.allow does not list as `: ` +wiring --lint-devkit [DIR] + fail on a dispatch in the devkit's own workflows (DIR, default this script's repo) whose type + wiring cannot read, or a type they listen for that none of them sends wiring --self-test """ @@ -49,6 +52,7 @@ ARRAY = re.compile(r"\b\w+\+?=\(([^)]*)\)") TYPE = re.compile(r"[a-z][a-z0-9-]*") SHVAR = re.compile(r"\$\{?(\w+)\}?") +WORKFLOW_DISPATCH = re.compile(r"/actions/workflows/[^/\s\"']+/dispatches") EXPR = re.compile(r"\$\{\{\s*(env|inputs|github)\.([\w-]+)\s*\}\}") @@ -60,6 +64,7 @@ def __init__(self) -> None: self.deploys: list[tuple[str, str]] = [] self.devkit: dict[str, list[str]] = {} self.calls: dict[str, dict[str, list[str]]] = {} + self.unread: list[str] = [] def art(self, key: str, repo: str = "", match: str = "") -> dict: a = self.artifacts.setdefault(key, {"pub": [], "pin": [], "read": [], "repos": set()}) @@ -258,13 +263,16 @@ def effects(doc: dict, repo: str, inputs: dict, callee, d: Path) -> list[tuple]: if not run: continue assigned = dict(ASSIGN.findall(run)) - sent = re.findall(r"event_type=([^\s\"']*)", sub(run, senv, inputs)) - out += [("send", t, "") for t in sent if t and "$" not in t] + sent = [t for t in re.findall(r"event_type=([^\s\"']*)", sub(run, senv, inputs)) if t] if "/dispatches" in run: - evs = [sub(v, {}, inputs) for k, v in senv.items() if "EVENT" in k] + sent += [sub(v, {}, inputs) for k, v in senv.items() if "EVENT" in k] # v0.7.0's notify-umbrella builds its types in an array (`events+=(…)`, #69). - evs += [w for a in ARRAY.findall(run) for w in a.split() if TYPE.fullmatch(w)] - out += [("send", t, "") for t in evs if "$" not in t] + sent += [w for a in ARRAY.findall(run) for w in a.split() if TYPE.fullmatch(w)] + repo_dispatch = "/dispatches" in WORKFLOW_DISPATCH.sub("", run) + # v0.15.0's JSON literal (#163); a workflow_dispatch call has no event type. + if repo_dispatch and not any(t and "$" not in t for t in sent): + out.append(("unread-dispatch", "", "")) + out += [("send", t, "") for t in sent if "$" not in t] for asset in release_files(run): name = sub(asset, assigned, {}).rsplit("/", 1)[-1] out.append(("asset", SHVAR.sub("", name), "")) @@ -346,6 +354,8 @@ def callee(repo: str, file: str, ref: str) -> tuple[dict | None, str]: pub = label + (f" ({', '.join(parts)})" if parts else "") if kind == "send": add(w.dispatch.setdefault(v, {"sent": [], "listen": []})["sent"], pub) + elif kind == "unread-dispatch": + add(w.unread, label) elif kind == "image": add(w.art(f"`{v}` image", repo, v)["pub"], pub) elif kind == "asset": @@ -465,6 +475,51 @@ def write_block(path: Path, block: str) -> None: path.write_text(f"{head}{START}\n\n{block}\n{END}{tail}", encoding="utf-8") +UNREAD = "a dispatch whose type wiring cannot read" +ORPHAN = "dispatch `%s` is listened for but never sent" + + +def lint_devkit(d: Path) -> list[str]: + """The devkit's own dispatch problems, its calls of itself read from DIR's working tree.""" + files = sorted((d / ".github" / "workflows").glob("*.y*ml")) + docs = {f.name: load(read(f), f) for f in files} + for doc in docs.values(): + for job in (doc.get("jobs") or {}).values(): + if (u := str(job.get("uses", ""))).startswith("./.github/workflows/"): + job["uses"] = f"{ORG}/marola-devkit/{u[2:]}@HEAD" + + calls = { + f: { + m[2] + for job in (doc.get("jobs") or {}).values() + if (m := USES.match(str(job.get("uses", "")))) and m[1] == "marola-devkit" + } + for f, doc in docs.items() + } + + def cyclic(f: str, path: tuple[str, ...] = ()) -> bool: + return f in path or any(cyclic(g, (*path, f)) for g in calls.get(f, ())) + + def callee(repo: str, file: str, ref: str) -> tuple[dict | None, str]: + return (docs.get(file) if repo == "marola-devkit" else None), "" + + sent, listen, problems = set(), set(), [] + for file, doc in docs.items(): + on = triggers(doc) + if set(on) == {"workflow_call"}: + continue + if cyclic(file): # effects() would recurse forever; GitHub rejects such a call too + add(problems, f"marola-devkit `{file}`: a reusable-workflow call cycle") + continue + listen |= set(on.get("repository_dispatch", {}).get("types") or []) + for kind, v, _ in effects(doc, "marola-devkit", {}, callee, d): + if kind == "send": + sent.add(v) + elif kind == "unread-dispatch": + add(problems, f"marola-devkit `{file}`: {UNREAD}") + return problems + [ORPHAN % t for t in sorted(listen - sent)] + + def check(w: Wiring, block: str, path: Path | None, allow: str) -> list[str]: out: list[str] = [] if path: @@ -474,8 +529,10 @@ def check(w: Wiring, block: str, path: Path | None, allow: str) -> list[str]: out.append(f"{path}'s wiring block is stale: run `just wiring {path}`") for t, d in sorted(w.dispatch.items()): if not (d["sent"] and d["listen"]): - side = "sent but never listened for" if d["sent"] else "listened for but never sent" - out.append(f"dispatch `{t}` is {side}") + out.append( + f"dispatch `{t}` is sent but never listened for" if d["sent"] else ORPHAN % t + ) + out += [f"{label}: {UNREAD}" for label in sorted(w.unread)] names = {k.replace("`", ""): a for k, a in w.artifacts.items()} allowed = set() for ln in map(str.strip, allow.splitlines()): @@ -545,6 +602,8 @@ def check(w: Wiring, block: str, path: Path | None, allow: str) -> list[str]: ".devkit/.github/workflows/bump-consumers.yml": 'on: {workflow_dispatch: {}}\njobs:\n b:\n steps:\n - run: scripts/bump-consumers.sh "$VERSION"\n', ".devkit/scripts/bump-consumers.sh": 'repos() { grep -v "^#" "$root/.github/consumers.txt"; }\nwhile read -r repo; do\n gh pr create -R "$repo" --head x\ndone < <(repos)\n', ".devkit/.github/consumers.txt": "# pin the devkit\nmarola-dev/marola\nmarola-dev/marola-app\n", + ".devkit/.github/workflows/ping.yml": 'on: {workflow_call: {}}\njobs:\n p:\n steps:\n - run: curl https://api.github.com/repos/x/dispatches -d \'{"event_type":"x"}\'\n', + "marola-ml/.github/workflows/ping.yml": "on: {push: {branches: [main]}}\njobs:\n a:\n uses: marola-dev/marola-devkit/.github/workflows/ping.yml@v0.3.1\n b:\n uses: marola-dev/marola-devkit/.github/workflows/ping.yml@v0.3.1\n", "marola-ml/scripts/corpus-fetch.sh": 'pin="$(<"$root/corpus.version")"\nbase="https://github.com/marola-dev/marola-corpus/releases/download"\n', } @@ -554,7 +613,7 @@ def check(w: Wiring, block: str, path: Path | None, allow: str) -> list[str]: CASES = { "dispatch_types_listened": "| `site-data-updated` | marola-app `ci.yml` (on a push to `main`) | marola-site `site.yml` |\n| `submodule-docs-updated` | marola-site `notify-umbrella.yml` (`notify-umbrella.yml@v0.3.1`, on a push to `main` touching `README.md`, `docs/**`) | marola `pointer-sync.yml` |", "dispatch_send_step": "| `site-data-updated` | marola-app `ci.yml` (on a push to `main`) | marola-site `site.yml` |", - "reusable_workflow_ref_and_event_type": "| `submodule-updated` | marola-app `notify-umbrella.yml` (`notify-umbrella.yml@v0.6.0`, on a push to `main`) | marola `pointer-sync.yml` |\n| a marola-devkit tag | marola-devkit | marola-app `flake.lock` v0.4.1 | `api-docs.yml` v0.4.1 (marola-app), v9.9.9 (marola-corpus); `gemini-review.yml` v0.5.0 (marola-site); `notify-umbrella.yml` v0.3.1 (marola-site), v0.6.0 (marola-app) |", + "reusable_workflow_ref_and_event_type": "| `submodule-updated` | marola-app `notify-umbrella.yml` (`notify-umbrella.yml@v0.6.0`, on a push to `main`) | marola `pointer-sync.yml` |\n| a marola-devkit tag | marola-devkit | marola-app `flake.lock` v0.4.1 | `api-docs.yml` v0.4.1 (marola-app), v9.9.9 (marola-corpus); `gemini-review.yml` v0.5.0 (marola-site); `notify-umbrella.yml` v0.3.1 (marola-site), v0.6.0 (marola-app); `ping.yml` v0.3.1 (marola-ml) |", "reusable_workflow_resolved_at_caller_ref": "| `submodule-docs-updated` | marola-site `notify-umbrella.yml` (`notify-umbrella.yml@v0.3.1`, on a push to `main` touching `README.md`, `docs/**`) | marola `pointer-sync.yml` |\n| `submodule-updated` | marola-app `notify-umbrella.yml` (`notify-umbrella.yml@v0.6.0`, on a push to `main`) | marola `pointer-sync.yml` |\n" + API_ROW, "release_upload_assets": "| `marola-corpus-.tar.gz` release asset | marola-corpus `release.yml` (on a `v*` tag) | marola-ml `corpus.version` | marola-ml `scripts/corpus-fetch.sh` |", @@ -618,6 +677,8 @@ def fake(url: str) -> str | None: with contextlib.redirect_stderr(err): w = scan(root) block = render(w) + unreadable = check(w, block, None, "") + w.unread.clear() # the check cases below count the other problems only shutil.rmtree(root / ".devkit" / ".git") # a Nix store path raw = f"https://raw.githubusercontent.com/{ORG}/%s/%s/.github/workflows/%s" served[raw % ("marola-devkit", "v0.3.1", "notify-umbrella.yml")] = FIXTURE[ @@ -672,6 +733,14 @@ def fake(url: str) -> str | None: v for k, v, _ in effects(array, "r", {}, lambda *a: (None, ""), root) if k == "send" ) cases.append(("dispatch_array_send", sent == ["submodule-docs-updated", "submodule-updated"])) + wf_dispatch = load( + "on: {workflow_dispatch: {}}\njobs:\n d:\n steps:\n - run: |\n" + " gh api repos/o/r/actions/workflows/x.yml/dispatches -f ref=main\n" + ' curl -X POST "https://api.github.com/repos/o/r/actions/workflows/1/dispatches"\n', + "workflow_dispatch fixture", + ) + kinds = {k for k, _, _ in effects(wf_dispatch, "r", {}, lambda *a: (None, ""), root)} + cases.append(("workflow_dispatch_api_not_unread", "unread-dispatch" not in kinds)) cases.append(("release_files_shell_forms", release_files(shell) == ["$f", "dist/a.tgz"])) cases.append(("missing_callee_warns", "gemini-review.yml@v0.5.0 not found" in err.getvalue())) cases.append(("uncloned_callee_warns", "marola-infra/x.yml@v1 not found" in err.getvalue())) @@ -713,6 +782,53 @@ def fake(url: str) -> str | None: ) ) cases.append(("check_reports_every_problem", len(every) == 4)) + cases.append( + ( + "check_fails_on_unreadable_dispatch_once_per_caller", + unreadable + == [ + f"marola-ml `ping.yml`: {UNREAD}", + f"marola-site `odd.yml`: {UNREAD}", + ], + ) + ) + with tempfile.TemporaryDirectory() as tmp: + wf = Path(tmp) / ".github" / "workflows" + wf.mkdir(parents=True) + (wf / "l.yml").write_text( + "on:\n repository_dispatch:\n types: [x]\njobs: {}\n", encoding="utf-8" + ) + lonely = lint_devkit(Path(tmp)) + n = "on: {workflow_call: {}}\njobs:\n n:\n steps:\n - env: {EVENT_TYPE: x}\n run: curl https://api.github.com/repos/x/dispatches -d y\n" + (wf / "n.yml").write_text(n, encoding="utf-8") + caller = "on: {workflow_dispatch: {}}\njobs:\n c:\n uses: %s\n" + (wf / "c.yml").write_text(caller % "./.github/workflows/n.yml", encoding="utf-8") + local = lint_devkit(Path(tmp)) + tagged = f"{ORG}/marola-devkit/.github/workflows/n.yml@v9.9.9" + (wf / "c.yml").write_text(caller % tagged, encoding="utf-8") + self_path = lint_devkit(Path(tmp)) + (wf / "n.yml").write_text( + n.replace("{EVENT_TYPE: x}", "{TOKEN: t}").replace("-d y", '-d \'{"event_type":"x"}\''), + encoding="utf-8", + ) + json_body = lint_devkit(Path(tmp)) + (wf / "c.yml").write_text(caller % "./.github/workflows/c.yml", encoding="utf-8") + loop = lint_devkit(Path(tmp)) + cases.append(("lint_devkit_fails_on_listener_without_sender", lonely == [ORPHAN % "x"])) + cases.append(("lint_devkit_local_call_sends", local == [])) + cases.append( + ( + "lint_devkit_call_cycle", + loop[0] == "marola-devkit `c.yml`: a reusable-workflow call cycle", + ) + ) + cases.append(("lint_devkit_tagged_self_path_resolves_locally", self_path == [])) + cases.append( + ( + "lint_devkit_fails_on_unreadable_dispatch", + json_body == [f"marola-devkit `c.yml`: {UNREAD}", ORPHAN % "x"], + ) + ) fails = [n for n, ok in cases if not ok] for name, ok in cases: print(f" {'ok ' if ok else 'FAIL'} {name}") @@ -734,11 +850,24 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--root", type=Path, default=Path.cwd()) ap.add_argument("--devkit", type=Path, help="default: /.devkit, else this script's repo") ap.add_argument("--name", default="marola", help="the umbrella's repo name") - ap.add_argument("--check", action="store_true") + mode = ap.add_mutually_exclusive_group() + mode.add_argument("--check", action="store_true") + mode.add_argument( + "--lint-devkit", + nargs="?", + const=Path(__file__).resolve().parents[1], + type=Path, + metavar="DIR", + ) ap.add_argument("--self-test", action="store_true") args = ap.parse_args(argv) if args.self_test: return self_test() + if args.lint_devkit: + problems = lint_devkit(args.lint_devkit) + for p in problems: + print(f"wiring: {p}", file=sys.stderr) + return 1 if problems else 0 w = scan(args.root, args.name, args.devkit) block = render(w) if args.check: