feat: 新增外部条目上游健康检查与每周巡检工作流 (#188)

## 背景
外部条目只存上游指针,上游仓库被删除、分支被重写或技能目录移动时,客户端安装会静默失败,目前没有任何机制能发现(例如
`dingtalk-cli` 的上游已 404,见修复 PR)。

## 变更
- **`scripts/catalog/check_upstream_health.py`**:只读,逐个检查
`skills/`、`agents/`、`teams/` 下的 `entry.json`:
- 失效(退出码 1):`gone` 上游不存在 · `bad-branch` 分支不存在 · `bad-ref` 固定提交无法获取 ·
`bad-path` `source.path`、根 `SKILL.md` 或 children 路径在分支最新提交下缺失
- 提示:`unreachable` 网络错误(不等于失效)· `new-skill` 集合有未声明子技能(发现规则直接复用
`gen-collection-children.py` 的 `discover_children`,两者结论一致)· `outdated`
固定 ref 落后于分支 · `unpinned` 未锁定 ref
  - 只稀疏检出 `SKILL.md`,39 个条目本地全量运行约 12 秒
- **`scripts/catalog/test_check_upstream_health.py`**:9 个基于本地 Git
仓库的单测,已加入 `i18n-validate.yml`。
- **`.github/workflows/upstream-health.yml`**:每周一 01:23 UTC 运行(可手动触发),仅在
`desirecore/market` 运行:
- 报告写入 Job Summary,并维护唯一一个带 `upstream-health` 标签的
Issue:有待处理项时创建或更新,全部正常时评论并关闭
  - 有失效条目时任务标红
- 权限仅 `contents: read` + `issues: write`,**不改写任何目录文件**;修复与 `source.ref`
更新仍通过评审 PR(与固定 ref 的审查语义一致)
- README 中英文补充用法。

## 当前主干的检查结果
- 失效 1:`dingtalk-cli`(已有修复 PR)
- 未声明子技能 3:`khazix-skills`、`marketingskills`、`mattpocock-skills`
- 可更新 21 个,未锁定 1 个
- `amap-jsapi-skill`、`luckin-my-coffee` 在本地因代理显示 `unreachable`,需要 CI
环境确认

## 验证
```
uv run scripts/catalog/test_check_upstream_health.py   # Ran 9 tests OK
uv run scripts/catalog/check_upstream_health.py        # 如上
uv run scripts/i18n/validate-i18n.py                   # 0 error(s)
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_0191D9g1FhRQzEGTefUn6mTb

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Yige <a@wyr.me>
This commit is contained in:
mashagua
2026-09-26 16:13:14 +08:00
committed by GitHub
parent 4dbfd4c563
commit 6cd60a7141
6 changed files with 487 additions and 0 deletions

View File

@@ -67,6 +67,7 @@ jobs:
run: |
uv run --quiet scripts/catalog/test_validate_catalog_metadata.py
uv run --quiet scripts/catalog/test_collection_generator.py
uv run --quiet scripts/catalog/test_check_upstream_health.py
- name: Validate catalog metadata
if: steps.changes.outputs.relevant == 'true'

87
.github/workflows/upstream-health.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Upstream Health
# Weekly check that every external pointer (skills/agents/teams entry.json)
# still resolves upstream. Results go to one tracking issue labelled
# `upstream-health`, which is updated in place and closed once nothing is
# actionable. Fixing an entry stays a normal reviewed PR; this workflow never
# rewrites catalog files.
on:
schedule:
- cron: "23 1 * * 1" # Mondays 01:23 UTC
workflow_dispatch:
permissions:
contents: read
issues: write
concurrency:
group: upstream-health
cancel-in-progress: false
jobs:
check:
if: github.repository == 'desirecore/market'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Check upstreams
id: check
run: |
set +e
uv run --quiet scripts/catalog/check_upstream_health.py --format markdown > report.md
code=$?
set -e
if [ "$code" -gt 1 ]; then
echo "health check crashed with exit code $code" >&2
exit "$code"
fi
echo "broken=$([ "$code" = 1 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
if grep -qE '^### (Broken|Undeclared upstream skills|Updates available) ' report.md; then
echo "actionable=true" >> "$GITHUB_OUTPUT"
else
echo "actionable=false" >> "$GITHUB_OUTPUT"
fi
cat report.md >> "$GITHUB_STEP_SUMMARY"
- name: Update tracking issue
env:
GH_TOKEN: ${{ github.token }}
ACTIONABLE: ${{ steps.check.outputs.actionable }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
label=upstream-health
gh label create "$label" --color D93F0B \
--description "Upstream availability of external catalog entries" 2>/dev/null || true
{
echo "_Updated by [Upstream Health]($RUN_URL) on $(date -u +%Y-%m-%d)._"
echo
echo "Fix entries through a normal PR: repoint or remove \`gone\` entries, correct \`bad-*\` paths, and review upstream changes before bumping \`source.ref\` for \`outdated\` entries (rerun \`scripts/gen-collection-children.py\` for collections). \`unreachable\` means a network error, not a confirmed failure."
echo
cat report.md
} > body.md
issue=$(gh issue list --label "$label" --state open --limit 1 --json number --jq '.[0].number // empty')
if [ "$ACTIONABLE" = "true" ]; then
if [ -n "$issue" ]; then
gh issue edit "$issue" --body-file body.md
else
gh issue create --title "Upstream health report" --label "$label" --body-file body.md
fi
elif [ -n "$issue" ]; then
gh issue comment "$issue" --body "All external entries resolve upstream and are current as of $RUN_URL. Closing."
gh issue close "$issue"
fi
- name: Fail when an entry is broken
if: steps.check.outputs.broken == 'true'
run: |
echo "::error::At least one external entry no longer resolves upstream; see the upstream-health issue."
exit 1

View File

@@ -209,6 +209,7 @@ uv run scripts/i18n/translate.py --check skills/web-access
uv run scripts/i18n/test_validate_i18n.py
uv run scripts/catalog/test_validate_catalog_metadata.py
uv run scripts/catalog/test_collection_generator.py
uv run scripts/catalog/test_check_upstream_health.py
```
Passing a skill path narrows skill-body validation, but market-level and sidecar checks still run. Choose the relevant test file for your change rather than treating every documentation edit as a reason to run all test suites.
@@ -221,10 +222,16 @@ uv run scripts/gen-collection-children.py --check
# Optional source-URL availability checks in addition to normal validation.
uv run scripts/i18n/validate-i18n.py --online
# Report deleted upstreams, missing branches or pinned refs, moved skill paths,
# undeclared collection children, and available updates for every external entry.
uv run scripts/catalog/check_upstream_health.py
```
Collection checking requires Git and network access. Mutable collections are reported and skipped because their generated inventory is not reproducible. Omitting `--check` runs the generator in write mode; review both the regenerated children and the corresponding sidecar facts before committing.
The upstream health check is read-only and exits non-zero when an entry is `gone` or has a `bad-branch`, `bad-ref`, or `bad-path`. `unreachable` is a network error, not a confirmed failure. The [upstream health workflow](.github/workflows/upstream-health.yml) runs it weekly and keeps one `upstream-health` issue current; fixes and `source.ref` bumps still go through reviewed PRs.
The [validation workflow](.github/workflows/i18n-validate.yml) is the source of truth for CI. It detects relevant paths and can skip catalog validation for documentation-only changes. A green skipped job is not evidence that Markdown links or bilingual documentation were checked; review those separately.
## Security and trust

View File

@@ -209,6 +209,7 @@ uv run scripts/i18n/translate.py --check skills/web-access
uv run scripts/i18n/test_validate_i18n.py
uv run scripts/catalog/test_validate_catalog_metadata.py
uv run scripts/catalog/test_collection_generator.py
uv run scripts/catalog/test_check_upstream_health.py
```
传入技能路径只缩小技能正文的校验范围,市场级与 sidecar 检查仍会执行。应根据改动选择相关测试文件,而不是每次修改文档都运行全部测试。
@@ -221,10 +222,16 @@ uv run scripts/gen-collection-children.py --check
# 可选:在常规校验之外检查来源 URL 是否可访问。
uv run scripts/i18n/validate-i18n.py --online
# 检查所有外部条目:上游是否已删除、分支或固定提交是否失效、技能路径是否移动、
# 集合是否有未声明的子技能,以及是否有可同步的更新。
uv run scripts/catalog/check_upstream_health.py
```
集合检查需要 Git 和网络。可变来源集合会被报告并跳过,因为无法保证生成清单可复现。省略 `--check` 会进入写入模式;提交前应同时检查重新生成的子技能和对应 sidecar 事实。
上游健康检查只读,不改写条目;出现 `gone`、`bad-branch`、`bad-ref` 或 `bad-path` 时以非零状态退出。`unreachable` 表示网络错误,不等于确认失效。[上游健康工作流](.github/workflows/upstream-health.yml)每周运行一次,并维护唯一一个 `upstream-health` Issue;修复条目和更新 `source.ref` 仍需通过经过评审的 PR。
[校验工作流](.github/workflows/i18n-validate.yml)是 CI 行为的权威来源。它会检测相关路径,纯文档改动可能跳过目录校验。跳过后显示成功的任务,不代表 Markdown 链接或中英文文档已被检查;这些内容仍需单独审查。
## 安全与信任边界

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Report the upstream health of every external pointer in the catalog.
External entries (`skills/*/entry.json`, `agents/*/entry.json`,
`teams/*/entry.json`) keep their content upstream, so a deleted repository, a
rewritten branch or a moved skill directory silently breaks installation. This
script looks at each upstream and reports, without rewriting anything:
gone upstream repository or URL no longer exists (deleted, private, 404/410)
bad-branch source.repoBranch no longer exists upstream
bad-ref pinned source.ref can no longer be fetched (history rewritten)
bad-path source.path, the root SKILL.md, or a declared child path is missing at
branch head
unreachable network error other than "not found"; treated as unknown, not broken
new-skill branch head has collection children that `children` does not declare
(same discovery rules as scripts/gen-collection-children.py)
outdated pinned source.ref is behind branch head; an update is available
unpinned entry follows a moving branch without source.ref
ok nothing to report
Path checks run against branch head: they answer "will this pointer still work
after the next ref bump", while `bad-ref` answers "does the pinned ref still
resolve". Only skills get path checks; agents and teams are AgentFS repositories.
Usage:
uv run scripts/catalog/check_upstream_health.py # text table
uv run scripts/catalog/check_upstream_health.py --format markdown
uv run scripts/catalog/check_upstream_health.py --format json
uv run scripts/catalog/check_upstream_health.py dingtalk-cli humanlayer-skills
Exit code: 1 when any entry is gone / bad-branch / bad-ref / bad-path, else 0.
Needs Git and network access.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
KINDS = ("skills", "agents", "teams")
BROKEN = ("gone", "bad-branch", "bad-ref", "bad-path")
ORDER = (*BROKEN, "unreachable", "new-skill", "outdated", "unpinned", "ok")
# Git asks for credentials when a public repository disappears; never prompt.
GIT_ENV = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}
NOT_FOUND = re.compile(
r"repository not found|authentication failed|could not read username|"
r"not found|does not exist|does not appear to be a git repository",
re.IGNORECASE,
)
def _load_generator():
"""Reuse the collection generator's discovery so both tools agree on children."""
path = REPO_ROOT / "scripts" / "gen-collection-children.py"
spec = importlib.util.spec_from_file_location("market_collection_generator", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
GENERATOR = _load_generator()
def git(*args: str, cwd: Path | None = None, timeout: int = 180) -> subprocess.CompletedProcess:
try:
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
env=GIT_ENV, timeout=timeout)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(args, 124, "", "timed out")
def last_line(text: str) -> str:
lines = [line for line in text.strip().splitlines() if line.strip()]
return lines[-1][:200] if lines else "no output"
def check_git(entry: dict, kind: str) -> list[tuple[str, str]]:
source = entry["source"]
url = source["repoUrl"]
branch = source.get("repoBranch")
ref = source.get("ref")
issues: list[tuple[str, str]] = []
query = ["--heads", url, branch] if branch else [url, "HEAD"]
remote = git("ls-remote", *query, timeout=90)
if remote.returncode != 0:
detail = last_line(remote.stderr)
return [("gone" if NOT_FOUND.search(remote.stderr) else "unreachable", detail)]
heads = [line.split()[0] for line in remote.stdout.splitlines() if line.strip()]
if branch and not heads:
return [("bad-branch", f"branch '{branch}' not found upstream")]
if not heads:
return [("unreachable", "remote advertised no HEAD")]
head = heads[0]
with tempfile.TemporaryDirectory(prefix=f"health-{entry['id']}-") as tmp:
repo = Path(tmp) / "repo"
clone = ["clone", "--quiet", "--depth", "1", "--filter=blob:none", "--no-checkout"]
if branch:
clone += ["--single-branch", "-b", branch]
cloned = git(*clone, url, str(repo), timeout=300)
if cloned.returncode != 0:
return [("unreachable", f"clone failed: {last_line(cloned.stderr)}")]
if ref and ref != head:
fetched = git("fetch", "--quiet", "--depth", "1", "origin", ref, cwd=repo, timeout=180)
if fetched.returncode != 0:
issues.append(("bad-ref", f"pinned {ref[:12]} is no longer fetchable"))
if kind == "skills":
issues += check_skill_paths(entry, repo)
if ref and ref != head:
issues.append(("outdated", f"{ref[:7]} -> {head[:7]}"))
if not ref:
issues.append(("unpinned", f"follows '{branch or 'HEAD'}' at {head[:7]}"))
return issues
def check_skill_paths(entry: dict, repo: Path) -> list[tuple[str, str]]:
# Materialise only SKILL.md files; the generator reads their frontmatter.
git("sparse-checkout", "set", "--no-cone", "SKILL.md", cwd=repo)
checkout = git("checkout", "--quiet", "HEAD", cwd=repo, timeout=300)
if checkout.returncode != 0:
return [("unreachable", f"checkout failed: {last_line(checkout.stderr)}")]
skill_dirs = {p.parent.relative_to(repo).as_posix() for p in repo.rglob("SKILL.md")
if ".git" not in p.parts}
skill_dirs = {"" if d == "." else d for d in skill_dirs}
issues: list[tuple[str, str]] = []
children = entry.get("children") or []
if children:
declared = {c["path"].strip("/") for c in children}
for child in children:
if child["path"].strip("/") not in skill_dirs:
issues.append(("bad-path", f"child '{child['id']}' -> '{child['path']}' has no SKILL.md"))
discovered = [c for c in GENERATOR.discover_children(repo) if c["path"] not in declared]
declared_ids = {c["id"] for c in children}
extra = [c["path"] for c in discovered if c["id"] not in declared_ids]
if extra:
shown = ", ".join(extra[:8]) + (f" (+{len(extra) - 8} more)" if len(extra) > 8 else "")
issues.append(("new-skill", shown))
else:
path = (entry["source"].get("path") or "").strip("/")
if path not in skill_dirs:
where = f"source.path '{path}'" if path else "repository root"
issues.append(("bad-path", f"{where} has no SKILL.md"))
return issues
def check_http(entry: dict) -> list[tuple[str, str]]:
url = entry["source"].get("url") or entry["source"].get("repoUrl")
request = urllib.request.Request(url, headers={"User-Agent": "desirecore-market-health", "Range": "bytes=0-0"})
try:
with urllib.request.urlopen(request, timeout=30):
return []
except urllib.error.HTTPError as err:
if err.code in (404, 410):
return [("gone", f"HTTP {err.code}")]
return [("unreachable", f"HTTP {err.code}")]
except Exception as err: # noqa: BLE001 - network errors are reported, not raised
return [("unreachable", str(err)[:200])]
def check_entry(path: Path) -> dict:
entry = json.loads(path.read_text(encoding="utf-8"))
kind = path.parent.parent.name
source = entry.get("source", {})
issues = check_git(entry, kind) if source.get("kind") == "git" else check_http(entry)
return {
"id": entry["id"],
"kind": kind.rstrip("s"),
"url": source.get("repoUrl") or source.get("url"),
"issues": [{"status": s, "detail": d} for s, d in (issues or [("ok", "")])],
}
def worst(result: dict) -> int:
return min(ORDER.index(i["status"]) for i in result["issues"])
def render_text(results: list[dict]) -> str:
rows = []
for r in sorted(results, key=lambda r: (worst(r), r["id"])):
for issue in sorted(r["issues"], key=lambda i: ORDER.index(i["status"])):
rows.append(f"{issue['status']:<11} {r['kind']:<6} {r['id']:<32} {issue['detail']}")
return "\n".join(rows)
def render_markdown(results: list[dict]) -> str:
counts = {s: 0 for s in ORDER}
for r in results:
counts[ORDER[worst(r)]] += 1
summary = " · ".join(f"{s}: {n}" for s, n in counts.items() if n)
out = [f"Checked {len(results)} external entries. {summary}", ""]
sections = [
("Broken — installation fails", BROKEN),
("Unreachable — network error, status unknown", ("unreachable",)),
("Undeclared upstream skills", ("new-skill",)),
("Updates available", ("outdated",)),
("Unpinned", ("unpinned",)),
]
for title, statuses in sections:
rows = [(r, i) for r in results for i in r["issues"] if i["status"] in statuses]
if not rows:
continue
out += [f"### {title} ({len(rows)})", "", "| Status | Entry | Detail |", "|---|---|---|"]
for r, i in sorted(rows, key=lambda x: (ORDER.index(x[1]["status"]), x[0]["id"])):
detail = i["detail"].replace("|", "\\|")
out.append(f"| `{i['status']}` | [{r['kind']}/{r['id']}]({r['url']}) | {detail} |")
out.append("")
ok = sorted(r["id"] for r in results if worst(r) == ORDER.index("ok"))
if ok:
out += [f"<details><summary>OK ({len(ok)})</summary>", "", ", ".join(f"`{i}`" for i in ok), "", "</details>"]
return "\n".join(out)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("ids", nargs="*", help="entry IDs to check (default: every external entry)")
parser.add_argument("--format", choices=("text", "markdown", "json"), default="text")
parser.add_argument("--jobs", type=int, default=8, help="parallel upstream checks")
args = parser.parse_args(argv)
paths = [p for kind in KINDS for p in sorted((REPO_ROOT / kind).glob("*/entry.json"))]
if args.ids:
paths = [p for p in paths if p.parent.name in set(args.ids)]
missing = set(args.ids) - {p.parent.name for p in paths}
if missing:
print(f"unknown entry id(s): {', '.join(sorted(missing))}", file=sys.stderr)
return 2
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
results = list(pool.map(check_entry, paths))
if args.format == "json":
print(json.dumps(results, ensure_ascii=False, indent=2))
elif args.format == "markdown":
print(render_markdown(results))
else:
print(render_text(results))
return 1 if any(i["status"] in BROKEN for r in results for i in r["issues"]) else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Unit tests for the upstream health check, using local Git repositories."""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
CHECKER_PATH = Path(__file__).with_name("check_upstream_health.py")
SPEC = importlib.util.spec_from_file_location("market_upstream_health", CHECKER_PATH)
assert SPEC is not None and SPEC.loader is not None
CHECKER = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = CHECKER
SPEC.loader.exec_module(CHECKER)
def sh(*args: str, cwd: Path) -> str:
return subprocess.run(args, cwd=cwd, check=True, capture_output=True, text=True).stdout.strip()
class UpstreamHealthTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.upstream = self.root / "upstream"
self.upstream.mkdir()
sh("git", "init", "--quiet", "-b", "main", cwd=self.upstream)
for key, value in (("user.name", "t"), ("user.email", "t@example.com"),
("uploadpack.allowFilter", "true"),
("uploadpack.allowAnySHA1InWant", "true")):
sh("git", "config", key, value, cwd=self.upstream)
self.first = self.commit({"skills/alpha/SKILL.md": "---\nname: alpha\ndescription: A.\n---\n"})
self.url = self.upstream.as_uri()
self.catalog = self.root / "catalog"
def tearDown(self) -> None:
self.tmp.cleanup()
def commit(self, files: dict[str, str]) -> str:
for rel, text in files.items():
path = self.upstream / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
sh("git", "add", "-A", cwd=self.upstream)
sh("git", "commit", "--quiet", "-m", "c", cwd=self.upstream)
return sh("git", "rev-parse", "HEAD", cwd=self.upstream)
def entry(self, **source) -> Path:
base = {"kind": "git", "repoUrl": self.url, "repoBranch": "main"}
data = {"id": "demo", "source": {**base, **source}}
children = data["source"].pop("children", None)
if children is not None:
data["children"] = children
path = self.catalog / "skills" / "demo" / "entry.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data), encoding="utf-8")
return path
def statuses(self, path: Path) -> dict[str, str]:
return {i["status"]: i["detail"] for i in CHECKER.check_entry(path)["issues"]}
def test_pinned_head_with_valid_path_is_ok(self) -> None:
result = self.statuses(self.entry(ref=self.first, path="skills/alpha"))
self.assertEqual(result, {"ok": ""})
def test_missing_repository_is_gone(self) -> None:
result = self.statuses(self.entry(repoUrl=(self.root / "missing").as_uri()))
self.assertIn("gone", result)
def test_missing_branch_is_bad_branch(self) -> None:
result = self.statuses(self.entry(repoBranch="release", ref=self.first))
self.assertIn("bad-branch", result)
def test_moved_skill_is_bad_path(self) -> None:
result = self.statuses(self.entry(ref=self.first, path="alpha"))
self.assertIn("bad-path", result)
def test_newer_head_is_outdated(self) -> None:
self.commit({"README.md": "x"})
result = self.statuses(self.entry(ref=self.first, path="skills/alpha"))
self.assertIn("outdated", result)
self.assertNotIn("bad-ref", result)
def test_unfetchable_ref_is_bad_ref(self) -> None:
result = self.statuses(self.entry(ref="0" * 40, path="skills/alpha"))
self.assertIn("bad-ref", result)
def test_unpinned_entry_is_reported(self) -> None:
result = self.statuses(self.entry(path="skills/alpha"))
self.assertIn("unpinned", result)
def test_collection_reports_missing_and_new_children(self) -> None:
head = self.commit({"skills/beta/SKILL.md": "---\nname: beta\ndescription: B.\n---\n"})
children = [{"id": "alpha", "path": "skills/alpha"}, {"id": "gamma", "path": "skills/gamma"}]
result = self.statuses(self.entry(ref=head, children=children))
self.assertIn("gamma", result["bad-path"])
self.assertEqual(result["new-skill"], "skills/beta")
def test_markdown_groups_broken_entries_first(self) -> None:
results = [
{"id": "a", "kind": "skill", "url": "u", "issues": [{"status": "outdated", "detail": "x"}]},
{"id": "b", "kind": "skill", "url": "u", "issues": [{"status": "gone", "detail": "y"}]},
]
text = CHECKER.render_markdown(results)
self.assertLess(text.index("Broken"), text.index("Updates available"))
if __name__ == "__main__":
unittest.main()