From 7118a9bf60239e628097e81fd5d77e517a1285db Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 20 Jul 2026 16:50:19 +0100 Subject: [PATCH] feat(ci): require discovery tags on publishable workers pr-checks now fails a publishable worker whose iii.worker.yaml has a missing or empty tags: list, since the registry indexes workers by tags for search and collections. Workers that opt out of publishing with interface_smoke: false (acp, iii-lsp) keep tags optional. Existing shape checks (list of strings) still apply to every worker. Adds validator tests for the new gate and documents the requirement in docs/sops/new-worker.md and the README CI section. --- .github/scripts/tests/test_validate_worker.py | 54 +++++++++++++++++-- .github/scripts/validate_worker.py | 21 ++++++-- README.md | 5 +- docs/sops/new-worker.md | 22 +++++++- 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/.github/scripts/tests/test_validate_worker.py b/.github/scripts/tests/test_validate_worker.py index 900646a50..4b9ec7d95 100644 --- a/.github/scripts/tests/test_validate_worker.py +++ b/.github/scripts/tests/test_validate_worker.py @@ -14,15 +14,31 @@ def make_worker(tmp_path: Path, name: str, version: str = "0.1.0", language: str = "rust", deploy: str = "binary", manifest_name: str = "Cargo.toml", - tests_dir: bool = True) -> Path: - """Returns the repo-root path; worker lives at //.""" + tests_dir: bool = True, + tags: list[str] | None = ("example",), + interface_smoke: bool | None = None) -> Path: + """Returns the repo-root path; worker lives at //. + + `tags` defaults to a valid non-empty list so the fixture is publishable and + passes the discovery-tags gate. Pass `tags=None` to omit the key entirely + or `tags=[]` for an empty list. `interface_smoke=False` marks the worker as + a publish opt-out (tags become optional). + """ w = tmp_path / name w.mkdir() (w / "README.md").write_text(f"# {name}\nhello\n") - (w / "iii.worker.yaml").write_text( + manifest = ( f'iii: v1\nname: {name}\nlanguage: {language}\n' f'deploy: {deploy}\nmanifest: {manifest_name}\n' ) + if interface_smoke is not None: + manifest += f'interface_smoke: {"true" if interface_smoke else "false"}\n' + if tags is not None: + if tags: + manifest += "tags:\n" + "".join(f" - {tag}\n" for tag in tags) + else: + manifest += "tags: []\n" + (w / "iii.worker.yaml").write_text(manifest) if manifest_name == "Cargo.toml": (w / manifest_name).write_text(f'[package]\nname = "{name}"\nversion = "{version}"\n') elif manifest_name == "package.json": @@ -106,7 +122,7 @@ def test_invalid_tags_fail(self, tmp_path): ]: case_root = tmp_path / case case_root.mkdir() - repo = make_worker(case_root, "smoke") + repo = make_worker(case_root, "smoke", tags=None) meta = repo / "smoke" / "iii.worker.yaml" meta.write_text(meta.read_text() + tags_yaml) init_git(repo) @@ -177,3 +193,33 @@ def test_version_less_than_base_fails(self, tmp_path): assert r.returncode != 0 assert "version" in r.stdout + r.stderr assert "less" in r.stdout + r.stderr + + def test_publishable_missing_tags_fails(self, tmp_path): + # A publishable worker (no interface_smoke opt-out) with no `tags:` key + # is not discoverable in the registry, so the gate rejects it. + repo = make_worker(tmp_path, "smoke", tags=None) + init_git(repo) + r = run_script(repo, "smoke", source_changed=["smoke"]) + assert r.returncode != 0, r.stdout + r.stderr + assert "non-empty tags" in r.stdout + r.stderr + + def test_publishable_empty_tags_fails(self, tmp_path): + repo = make_worker(tmp_path, "smoke", tags=[]) + init_git(repo) + r = run_script(repo, "smoke", source_changed=["smoke"]) + assert r.returncode != 0, r.stdout + r.stderr + assert "non-empty tags" in r.stdout + r.stderr + + def test_publishable_with_tags_passes(self, tmp_path): + repo = make_worker(tmp_path, "smoke", tags=["x"]) + init_git(repo) + r = run_script(repo, "smoke", source_changed=["smoke"]) + assert r.returncode == 0, r.stdout + r.stderr + + def test_interface_smoke_optout_missing_tags_passes(self, tmp_path): + # `interface_smoke: false` workers skip registry publish, so tags stay + # optional for them (mirrors acp/lsp in the tree). + repo = make_worker(tmp_path, "smoke", tags=None, interface_smoke=False) + init_git(repo) + r = run_script(repo, "smoke", source_changed=["smoke"]) + assert r.returncode == 0, r.stdout + r.stderr diff --git a/.github/scripts/validate_worker.py b/.github/scripts/validate_worker.py index 24ab1cb24..981bb132a 100644 --- a/.github/scripts/validate_worker.py +++ b/.github/scripts/validate_worker.py @@ -118,12 +118,23 @@ def soft(msg: str) -> None: hard( f"{worker}/iii.worker.yaml language must be 'rust' | 'node' | 'python' | 'javascript'" ) + # Registry discovery/collections index workers by their `tags:` list, so + # a publishable worker must ship a non-empty one. A worker opts out of + # publishing with `interface_smoke: false` (mirror the publish gate in + # .github/workflows/release.yml: publish runs when interface_smoke is not + # False); those keep tags optional. Shape (list of strings) is enforced + # for every worker regardless. + publishable = m.raw.get("interface_smoke") is not False tags = m.raw.get("tags") - if tags is not None: - if not isinstance(tags, list): - hard(f"{worker}/iii.worker.yaml tags must be a list") - elif any(not isinstance(tag, str) for tag in tags): - hard(f"{worker}/iii.worker.yaml tags entries must be strings") + if tags is not None and not isinstance(tags, list): + hard(f"{worker}/iii.worker.yaml tags must be a list") + elif isinstance(tags, list) and any(not isinstance(tag, str) for tag in tags): + hard(f"{worker}/iii.worker.yaml tags entries must be strings") + elif publishable and not tags: + hard( + f"{worker}/iii.worker.yaml must set a non-empty tags: list for " + f"registry discovery (see docs/sops/new-worker.md)" + ) # The release archive's binary is named after `bin` (defaulting to the # worker name); the resolver looks it up by worker name. They must match # or `iii worker add {worker}` fails with "Binary not found in archive". diff --git a/README.md b/README.md index a521b024b..6d8df9172 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,10 @@ reading each worker's `iii.worker.yaml`, then routes: The `pr-checks` job additionally enforces, per changed worker: `README.md` present, `iii.worker.yaml` valid, `tests/` non-empty, and the manifest -version is greater than the version on the PR's base branch. +version is greater than the version on the PR's base branch. It also requires +a non-empty `tags:` list on every publishable worker for registry discovery +(workers with `interface_smoke: false` are exempt) — see the +[Discovery tags step](docs/sops/new-worker.md#discovery-tags-required). Full reference (discovery buckets, interface boot smoke, e2e workflows): [`docs/architecture/testing-and-ci.md`](docs/architecture/testing-and-ci.md). diff --git a/docs/sops/new-worker.md b/docs/sops/new-worker.md index 2d88c514d..ae9ea6069 100644 --- a/docs/sops/new-worker.md +++ b/docs/sops/new-worker.md @@ -46,6 +46,26 @@ Pick a scaffold by `deploy` + `language`: `iii.worker.yaml` must declare valid `deploy` (`binary` | `image` | `bundle`) and `language` (`rust` | `javascript` | `node` | `python`). +### Discovery tags (required) + +Every **publishable** worker must set a non-empty `tags:` list in +`iii.worker.yaml`. Tags are how the registry indexes a worker for search and +collections, so a worker without them is effectively undiscoverable. + +```yaml +# iii.worker.yaml +tags: + - sql + - database + - storage +``` + +Keep tags lowercase and specific to what the worker does. `pr-checks` enforces +this (`.github/scripts/validate_worker.py`): a publishable worker with a missing +or empty `tags:` list is a hard error. Workers that opt out of publishing with +`interface_smoke: false` (e.g. `acp`, `iii-lsp`) are exempt — tags stay optional +for them. + ## 3. Repo registration Add a row to the **Modules** table in [`README.md`](../../README.md). Every @@ -58,7 +78,7 @@ runs: | Job | What it enforces | |---|---| -| `pr-checks` | `README.md` present; `iii.worker.yaml` valid; manifest version ≥ base; `tests/` non-empty | +| `pr-checks` | `README.md` present; `iii.worker.yaml` valid; non-empty `tags:` on publishable workers (see [Discovery tags](#discovery-tags-required)); manifest version ≥ base; `tests/` non-empty | | Language job | Rust: `fmt`, `clippy -D warnings`, `test`; Node: `biome ci`, `npm test`; Python: `ruff`, `pytest` | | `interface-smoke` | Rust only: build from source, boot engine + worker, collect non-empty interface |