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
14 changes: 8 additions & 6 deletions .github/scripts/build_skills_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
``<worker>/skills/**/*.md`` and produces the JSON body expected by the
workers-registry endpoint. Skill paths map to keys as:

<worker>/skills/SKILL.md -> "index.md"
<worker>/skills/index.md -> "index.md" (legacy fallback)
<worker>/skill.md -> "index.md" (legacy fallback)
<worker>/skills/SKILL.md -> "SKILL.md"
<worker>/skills/index.md -> "SKILL.md" (legacy fallback)
<worker>/skill.md -> "SKILL.md" (legacy fallback)
<worker>/skills/<rel>.md -> "skills/<rel>.md" (except SKILL.md / index.md)

If no non-empty markdown is found the script writes ``skip=true`` to
Expand All @@ -26,6 +26,8 @@

KEY_RE = re.compile(r"^[a-z0-9][a-z0-9._/\-]*\.md$", re.IGNORECASE)

TOP_SKILL_KEY = "SKILL.md"


def _read_nonempty(path: pathlib.Path) -> str | None:
body = path.read_text(encoding="utf-8")
Expand All @@ -35,7 +37,7 @@ def _read_nonempty(path: pathlib.Path) -> str | None:
def _resolve_top_skill(
worker_root: pathlib.Path,
) -> tuple[str | None, pathlib.Path | None]:
"""Return ``(index.md body, winning path)`` from the top-of-tree candidates.
"""Return ``(overview body, winning path)`` from the top-of-tree candidates.

Resolution order: ``skills/SKILL.md``, then legacy ``skills/index.md``, then
legacy ``skill.md``. When multiple candidates exist, a GitHub Actions
Expand Down Expand Up @@ -63,7 +65,7 @@ def _resolve_top_skill(
def collect_skills(worker_root: pathlib.Path) -> dict[str, str]:
"""Return a ``{payload-key: markdown-body}`` map for one worker directory.

The worker overview is always published as registry key ``index.md``,
The worker overview is always published as registry key ``SKILL.md``,
sourced from ``skills/SKILL.md`` when present. Empty bodies are skipped
silently so blank placeholder files don't end up in the registry.
"""
Expand All @@ -75,7 +77,7 @@ def collect_skills(worker_root: pathlib.Path) -> dict[str, str]:

top_body, _ = _resolve_top_skill(worker_root)
if top_body is not None:
skills["index.md"] = top_body
skills[TOP_SKILL_KEY] = top_body

if leaves_dir.is_dir():
for path in sorted(leaves_dir.rglob("*.md")):
Expand Down
97 changes: 97 additions & 0 deletions .github/scripts/parse_publish_workers_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Parse workflow_dispatch workers input into a GitHub Actions matrix JSON array.

Accepts a comma-separated list of worker folder names or the special value
``all`` (every worker allowed by create-tag). Writes the deduplicated list as
JSON to ``$GITHUB_OUTPUT`` under ``--out-key`` (default ``matrix``).
"""
from __future__ import annotations

import argparse
import json
import os
import sys

ALLOWED_WORKERS: tuple[str, ...] = (
"acp",
"coder",
"console",
"database",
"harness",
"iii-directory",
"image-resize",
"mcp",
"shell",
"storage"
)

_ALLOWED_SET = frozenset(ALLOWED_WORKERS)


def parse_workers(raw: str) -> list[str]:
"""Return a deduplicated worker list preserving first-seen order."""
text = raw.strip()
if not text:
raise ValueError("workers input is empty")

if text.lower() == "all":
return list(ALLOWED_WORKERS)

names: list[str] = []
for part in text.split(","):
name = part.strip()
if name:
names.append(name)

if not names:
raise ValueError("workers input is empty")

unknown = sorted({n for n in names if n not in _ALLOWED_SET})
if unknown:
allowed = ", ".join(ALLOWED_WORKERS)
raise ValueError(
f"unknown worker(s): {', '.join(unknown)}. "
f"Allowed: {allowed} (or use all)"
)

seen: set[str] = set()
deduped: list[str] = []
for name in names:
if name not in seen:
seen.add(name)
deduped.append(name)
return deduped


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--workers",
required=True,
help='Comma-separated worker names or "all"',
)
parser.add_argument(
"--out-key",
default="matrix",
help="GITHUB_OUTPUT key for the JSON array (default: matrix)",
)
args = parser.parse_args()

try:
workers = parse_workers(args.workers)
except ValueError as exc:
print(f"::error::{exc}", file=sys.stderr)
return 1

payload = json.dumps(workers)
gha_out = os.environ.get("GITHUB_OUTPUT")
if gha_out:
with open(gha_out, "a", encoding="utf-8") as f:
f.write(f"{args.out_key}={payload}\n")

print(f"::notice::publish skills for {len(workers)} worker(s): {', '.join(workers)}")
return 0


if __name__ == "__main__":
sys.exit(main())
57 changes: 57 additions & 0 deletions .github/scripts/test_build_skills_payload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Unit tests for build_skills_payload.collect_skills."""

from __future__ import annotations

import pathlib
import sys
import tempfile
import unittest

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from build_skills_payload import TOP_SKILL_KEY, collect_skills # noqa: E402


class CollectSkillsTests(unittest.TestCase):
def test_single_skill_md_publishes_bundle_root_skill_md(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "my-worker"
(root / "skills").mkdir(parents=True)
(root / "skills" / "SKILL.md").write_text("# My Worker\n", encoding="utf-8")
skills = collect_skills(root)
self.assertEqual(skills, {TOP_SKILL_KEY: "# My Worker\n"})

def test_legacy_index_md_publishes_as_skill_md(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "legacy-worker"
(root / "skills").mkdir(parents=True)
(root / "skills" / "index.md").write_text("# Legacy\n", encoding="utf-8")
skills = collect_skills(root)
self.assertEqual(skills, {TOP_SKILL_KEY: "# Legacy\n"})

def test_skill_md_plus_nested_extra(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "nested-worker"
(root / "skills" / "extra").mkdir(parents=True)
(root / "skills" / "SKILL.md").write_text("# Overview\n", encoding="utf-8")
(root / "skills" / "extra" / "topic.md").write_text("# Topic\n", encoding="utf-8")
skills = collect_skills(root)
self.assertEqual(
skills,
{
TOP_SKILL_KEY: "# Overview\n",
"skills/extra/topic.md": "# Topic\n",
},
)

def test_empty_skill_md_skipped(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp) / "empty-worker"
(root / "skills").mkdir(parents=True)
(root / "skills" / "SKILL.md").write_text(" \n", encoding="utf-8")
skills = collect_skills(root)
self.assertEqual(skills, {})


if __name__ == "__main__":
unittest.main()
69 changes: 69 additions & 0 deletions .github/scripts/tests/test_parse_publish_workers_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Tests for .github/scripts/parse_publish_workers_input.py."""
from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

import pytest

SCRIPT = Path(__file__).resolve().parents[1] / "parse_publish_workers_input.py"

# Import after path is known.
sys.path.insert(0, str(SCRIPT.parent))
from parse_publish_workers_input import ALLOWED_WORKERS, parse_workers # noqa: E402


class TestParseWorkers:
def test_all_expands_to_full_list(self):
assert parse_workers("all") == list(ALLOWED_WORKERS)
assert parse_workers(" ALL ") == list(ALLOWED_WORKERS)

def test_comma_separated_list(self):
assert parse_workers("shell,coder") == ["shell", "coder"]

def test_dedupe_preserves_order(self):
assert parse_workers("shell,coder,shell") == ["shell", "coder"]

def test_whitespace_trimmed(self):
assert parse_workers(" shell , coder ") == ["shell", "coder"]

def test_unknown_worker_raises(self):
with pytest.raises(ValueError, match="unknown worker"):
parse_workers("shell,not-a-worker")

def test_empty_raises(self):
with pytest.raises(ValueError, match="empty"):
parse_workers("")
with pytest.raises(ValueError, match="empty"):
parse_workers(" , , ")


def run_script(workers: str, github_output: Path) -> subprocess.CompletedProcess[str]:
env = {**os.environ, "GITHUB_OUTPUT": str(github_output)}
return subprocess.run(
[sys.executable, str(SCRIPT), "--workers", workers],
capture_output=True,
text=True,
env=env,
)


def test_cli_writes_matrix_to_github_output(tmp_path):
out_file = tmp_path / "output"
out_file.write_text("")
result = run_script("shell,coder", out_file)
assert result.returncode == 0
lines = out_file.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 1
key, value = lines[0].split("=", 1)
assert key == "matrix"
assert json.loads(value) == ["shell", "coder"]


def test_cli_unknown_worker_exits_nonzero():
result = run_script("bogus", Path("/dev/null"))
assert result.returncode == 1
assert "unknown worker" in result.stderr
66 changes: 66 additions & 0 deletions .github/workflows/_publish-worker-skills.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Publish worker skills to registry

on:
workflow_call:
inputs:
worker:
description: 'Worker folder name'
required: true
type: string
version:
description: 'Registry tag channel (latest, next, ...)'
required: true
type: string
api_url:
description: 'Workers registry base URL'
required: false
type: string
default: 'https://api.workers.iii.dev'
secrets:
WORKERS_REGISTRY_API_KEY:
required: true

jobs:
publish:
name: POST /w/${{ inputs.worker }}/skills
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- name: Build skills payload
id: skills_payload
env:
WORKER: ${{ inputs.worker }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
python3 .github/scripts/build_skills_payload.py \
--worker "$WORKER" \
--version "$VERSION" \
--out skills-payload.json

- name: POST /w/<worker>/skills
if: steps.skills_payload.outputs.skip != 'true'
env:
API_URL: ${{ inputs.api_url }}
API_KEY: ${{ secrets.WORKERS_REGISTRY_API_KEY }}
WORKER: ${{ inputs.worker }}
run: |
set -euo pipefail
if [[ -z "$API_KEY" ]]; then
echo "::error::WORKERS_REGISTRY_API_KEY secret is not set"
exit 1
fi
http=$(curl -sS -o skills-response.json -w '%{http_code}' \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-X POST "$API_URL/w/$WORKER/skills" \
--data-binary @skills-payload.json)
echo "HTTP $http"
cat skills-response.json
if [[ "$http" != "200" ]]; then
echo "::error::publish skills failed with HTTP $http"
exit 1
fi
3 changes: 0 additions & 3 deletions .github/workflows/create-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,9 @@ on:
- iii-lsp
- iii-lsp-vscode
- image-resize
- llm-budget
- mcp
- shell
- storage
- todo-worker
- todo-worker-python
bump:
description: 'Version bump'
required: true
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/database-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
- name: Install iii engine (next)
run: |
curl -fsSL --retry 3 --retry-connrefused --retry-delay 5 \
https://install.iii.dev/iii/main/install.sh | sh -s -- --next
https://install.iii.dev/iii/main/install.sh | sh
Comment on lines 46 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

url="https://install.iii.dev/iii/main/install.sh"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT

curl -fsSL "$url" > "$tmp"

echo "Inspecting installer defaults from: $url"
grep -nE 'next|stable|channel|default|--next' "$tmp" || true

Repository: iii-hq/workers

Length of output: 1538


Fix “Install iii engine (next)” to actually install the “next” channel

The workflow runs https://install.iii.dev/iii/main/install.sh | sh without --next, but the installer defaults use_next=false and only switches to next when --next is provided—so CI will install the stable engine while the step is labeled “(next)”. Restore --next (e.g., pass -s -- --next to sh) or rename the step to match the installed channel.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/database-e2e.yml around lines 46 - 49, The "Install iii
engine (next)" step is calling the installer without the --next flag so it
installs stable; update the run command invoked in the "Install iii engine
(next)" step to pass the next-channel flag to the installer (for example invoke
sh with -s -- --next or otherwise append --next to the installer invocation) so
the installer receives --next and actually installs the next channel; ensure the
change is applied to the run block that currently pipes
https://install.iii.dev/iii/main/install.sh into sh.

echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Verify engine
Expand Down
Loading
Loading