Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions .github/scripts/tests/test_validate_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <root>/<name>/."""
tests_dir: bool = True,
tags: list[str] | None = ("example",),
interface_smoke: bool | None = None) -> Path:
"""Returns the repo-root path; worker lives at <root>/<name>/.

`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":
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
21 changes: 16 additions & 5 deletions .github/scripts/validate_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
22 changes: 21 additions & 1 deletion docs/sops/new-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

Expand Down
Loading