Skip to content

fix(ci): cargo command for rust workers + fetch annotated tag content #240

fix(ci): cargo command for rust workers + fetch annotated tag content

fix(ci): cargo command for rust workers + fetch annotated tag content #240

Workflow file for this run

name: CI
on:
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
# ──────────────────────────────────────────────────────────────
# Discover: enumerate changed workers, read their iii.worker.yaml,
# bucket them per language for the matrix jobs below.
# ──────────────────────────────────────────────────────────────
discover:
name: Discover changed workers
runs-on: ubuntu-latest
outputs:
rust: ${{ steps.bucket.outputs.rust }}
node: ${{ steps.bucket.outputs.node }}
python: ${{ steps.bucket.outputs.python }}
all: ${{ steps.bucket.outputs.all }}
source_changed: ${{ steps.bucket.outputs.source_changed }}
vscode_changed: ${{ steps.bucket.outputs.vscode_changed }}
any: ${{ steps.bucket.outputs.any }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute base ref
id: base
env:
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch || 'main' }}
run: |
git fetch --no-tags --depth=1 origin "$BASE_REF" || true
echo "ref=origin/$BASE_REF" >> "$GITHUB_OUTPUT"
- name: Bucket changed workers
id: bucket
env:
BASE: ${{ steps.base.outputs.ref }}
run: |
python3 - <<'PY'
import json, os, pathlib, subprocess, sys
base = os.environ["BASE"]
try:
changed = subprocess.check_output(
["git", "diff", "--name-only", f"{base}...HEAD"], text=True
).splitlines()
except subprocess.CalledProcessError:
changed = subprocess.check_output(
["git", "diff", "--name-only", "HEAD~1...HEAD"], text=True
).splitlines()
repo_root = pathlib.Path(".").resolve()
ignore = {".git", ".github", "registry", "target", "node_modules"}
worker_dirs = sorted(
p.name
for p in repo_root.iterdir()
if p.is_dir() and not p.name.startswith(".") and p.name not in ignore
and (p / "iii.worker.yaml").exists()
)
# Files inside a worker dir that don't count as a "source" change.
# If a worker only touched these, version-bump and tests/ gates are
# downgraded to notices in pr-checks.
import fnmatch
metadata_globs = (
"iii.worker.yaml",
"README.md",
"AGENTS.md",
"AGENTS-*.md",
"Cargo.lock",
)
def is_metadata(rel: str) -> bool:
return any(fnmatch.fnmatch(rel, g) for g in metadata_globs)
changed_workers = set()
worker_files: dict[str, list[str]] = {}
vscode_changed = False
for f in changed:
parts = f.split("/", 1)
if len(parts) < 2:
continue
top, rel = parts[0], parts[1]
if top == "iii-lsp-vscode":
vscode_changed = True
continue
if top in worker_dirs:
changed_workers.add(top)
worker_files.setdefault(top, []).append(rel)
source_changed = sorted(
w for w in changed_workers
if any(not is_metadata(rel) for rel in worker_files.get(w, []))
)
rust, node, python = [], [], []
for w in sorted(changed_workers):
meta_path = pathlib.Path(w) / "iii.worker.yaml"
lang = None
for line in meta_path.read_text().splitlines():
s = line.strip()
if s.startswith("language:"):
lang = s.split(":", 1)[1].strip()
break
if lang == "rust":
rust.append(w)
elif lang == "node":
node.append(w)
elif lang == "python":
python.append(w)
else:
print(f"::warning::{w} has unknown language={lang}")
out = open(os.environ["GITHUB_OUTPUT"], "a")
out.write(f"rust={json.dumps(rust)}\n")
out.write(f"node={json.dumps(node)}\n")
out.write(f"python={json.dumps(python)}\n")
out.write(f"all={json.dumps(sorted(changed_workers))}\n")
out.write(f"source_changed={json.dumps(source_changed)}\n")
out.write(f"vscode_changed={'true' if vscode_changed else 'false'}\n")
out.write(f"any={'true' if (changed_workers or vscode_changed) else 'false'}\n")
out.close()
print(
f"::notice::changed rust={rust} node={node} python={python} "
f"vscode={vscode_changed} source_changed={source_changed}"
)
PY
# ──────────────────────────────────────────────────────────────
# PR gates: readme present, manifest version > main, iii.worker.yaml valid.
# Runs once per changed worker (excluding iii-lsp-vscode).
# ──────────────────────────────────────────────────────────────
pr-checks:
name: "${{ matrix.worker }}: PR checks"
needs: discover
if: needs.discover.outputs.all != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
worker: ${{ fromJSON(needs.discover.outputs.all) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch base
env:
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch || 'main' }}
run: git fetch --no-tags --depth=1 origin "$BASE_REF"
- name: Install pyyaml
run: pip install --quiet pyyaml
- name: Validate worker
env:
WORKER: ${{ matrix.worker }}
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch || 'main' }}
SOURCE_CHANGED: ${{ needs.discover.outputs.source_changed }}
run: |
python3 - <<'PY'
import json, os, pathlib, re, subprocess, sys, yaml
worker = os.environ["WORKER"]
base = os.environ["BASE_REF"]
source_changed = set(json.loads(os.environ.get("SOURCE_CHANGED") or "[]"))
strict = worker in source_changed
root = pathlib.Path(worker)
errs = []
def soft(msg: str) -> None:
# Strict for workers with real source changes; notice otherwise.
if strict:
errs.append(msg)
else:
print(f"::notice::{msg} (skipped: {worker} only changed metadata)")
# 1. README.md present and non-empty
readme = root / "README.md"
if not readme.exists():
errs.append(f"{worker}/README.md is missing")
elif readme.stat().st_size == 0:
errs.append(f"{worker}/README.md is empty")
# 2. iii.worker.yaml parses and has required fields
meta_path = root / "iii.worker.yaml"
if not meta_path.exists():
errs.append(f"{worker}/iii.worker.yaml is missing")
meta = {}
else:
meta = yaml.safe_load(meta_path.read_text()) or {}
for key in ("name", "language", "deploy", "manifest"):
if not meta.get(key):
errs.append(f"{worker}/iii.worker.yaml is missing key: {key}")
if meta.get("name") and meta["name"] != worker:
errs.append(
f"{worker}/iii.worker.yaml name={meta['name']!r} does not match folder"
)
if meta.get("deploy") not in ("binary", "image"):
errs.append(
f"{worker}/iii.worker.yaml deploy must be 'binary' or 'image'"
)
if meta.get("language") not in ("rust", "node", "python"):
errs.append(
f"{worker}/iii.worker.yaml language must be 'rust' | 'node' | 'python'"
)
# 3. manifest version on PR > version on base
def read_version(text: str, kind: str) -> str | None:
if kind == "Cargo.toml":
for line in text.splitlines():
m = re.match(r'^version\s*=\s*"([^"]+)"', line.strip())
if m:
return m.group(1)
elif kind == "package.json":
import json
return json.loads(text).get("version")
elif kind == "pyproject.toml":
for line in text.splitlines():
m = re.match(r'^version\s*=\s*"([^"]+)"', line.strip())
if m:
return m.group(1)
return None
def parse_semver(v: str) -> tuple:
# Strip prerelease/build for ordering: <X>.<Y>.<Z>[-pre]
core, _, pre = v.partition("-")
parts = [int(x) for x in core.split(".")]
while len(parts) < 3:
parts.append(0)
# No pre = greater than any pre at the same core
return (tuple(parts), 1 if not pre else 0, pre)
manifest_name = meta.get("manifest")
if manifest_name:
manifest_path = root / manifest_name
if not manifest_path.exists():
errs.append(f"{worker}/{manifest_name} not found")
else:
pr_ver = read_version(manifest_path.read_text(), manifest_name)
if not pr_ver:
errs.append(f"could not read version from {worker}/{manifest_name}")
else:
try:
base_blob = subprocess.check_output(
["git", "show", f"origin/{base}:{worker}/{manifest_name}"],
text=True,
stderr=subprocess.DEVNULL,
)
base_ver = read_version(base_blob, manifest_name)
except subprocess.CalledProcessError:
base_ver = None # New worker on this PR
if base_ver is None:
print(
f"::notice::{worker}: new worker on this PR "
f"(no base version), pr={pr_ver}"
)
elif parse_semver(pr_ver) <= parse_semver(base_ver):
soft(
f"{worker}/{manifest_name} version must be greater than base: "
f"pr={pr_ver} base={base_ver}"
)
else:
print(
f"::notice::{worker}: version {base_ver} -> {pr_ver}"
)
# 4. tests dir present and non-empty
tests_dir = root / "tests"
if not tests_dir.exists() or not any(tests_dir.iterdir()):
soft(f"{worker}/tests/ is missing or empty")
if errs:
for e in errs:
print(f"::error::{e}")
sys.exit(1)
print(f"::notice::{worker}: all PR checks passed")
PY
# ──────────────────────────────────────────────────────────────
# Rust per-worker lint + test
# ──────────────────────────────────────────────────────────────
rust:
name: "${{ matrix.worker }}: rust lint + test"
needs: discover
if: needs.discover.outputs.rust != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
worker: ${{ fromJSON(needs.discover.outputs.rust) }}
defaults:
run:
working-directory: ${{ matrix.worker }}
steps:
- uses: actions/checkout@v4
- name: Rewrite SSH to HTTPS for public deps
run: git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: ${{ matrix.worker }} -> target
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run tests
run: cargo test --all-features
# ──────────────────────────────────────────────────────────────
# Node per-worker lint (biome) + test
# ──────────────────────────────────────────────────────────────
node:
name: "${{ matrix.worker }}: node lint + test"
needs: discover
if: needs.discover.outputs.node != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
worker: ${{ fromJSON(needs.discover.outputs.node) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
working-directory: ${{ matrix.worker }}
run: |
if [ -f package-lock.json ]; then
npm ci
else
npm install
fi
- name: Biome lint
run: npx --yes @biomejs/biome@2.4.10 ci ${{ matrix.worker }}
- name: Run tests
if: hashFiles(format('{0}/tests/**', matrix.worker)) != ''
working-directory: ${{ matrix.worker }}
run: npm test
# ──────────────────────────────────────────────────────────────
# Python per-worker lint (ruff) + test (pytest)
# ──────────────────────────────────────────────────────────────
python:
name: "${{ matrix.worker }}: python lint + test"
needs: discover
if: needs.discover.outputs.python != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
worker: ${{ fromJSON(needs.discover.outputs.python) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install ruff + pytest
run: pip install --quiet ruff pytest
- name: Ruff lint
run: ruff check ${{ matrix.worker }}
- name: Ruff format check
run: ruff format --check ${{ matrix.worker }}
- name: Install worker
working-directory: ${{ matrix.worker }}
run: pip install -e ".[dev]" || pip install -e .
- name: Run pytest
if: hashFiles(format('{0}/tests/**', matrix.worker)) != ''
working-directory: ${{ matrix.worker }}
run: pytest -q
# ──────────────────────────────────────────────────────────────
# iii-lsp-vscode: special-case (VS Code extension, not a standard worker)
# ──────────────────────────────────────────────────────────────
iii-lsp-vscode:
name: "iii-lsp-vscode: lint + test"
needs: discover
if: needs.discover.outputs.vscode_changed == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: iii-lsp-vscode
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Package check (dry run)
run: npm run package:check