diff --git a/.github/workflows/health.yml b/.github/workflows/health.yml new file mode 100644 index 0000000..eb58251 --- /dev/null +++ b/.github/workflows/health.yml @@ -0,0 +1,81 @@ +name: Health + +on: + schedule: + - cron: "23 6 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: health + cancel-in-progress: false + +jobs: + check: + name: Check entries + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + outputs: + findings: ${{ steps.check.outputs.findings }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - run: python -m pip install --require-hashes -r requirements.txt + - name: Check every active entry + id: check + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + python scripts/health.py --scan-sources --out build/health.md + if [ -s build/health.md ]; then + echo "findings=true" >> "$GITHUB_OUTPUT" + else + echo "findings=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.check.outputs.findings == 'true' + with: + name: health + path: build/health.md + if-no-files-found: error + retention-days: 7 + + issue: + name: Update the health issue + needs: check + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + issues: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + if: needs.check.outputs.findings == 'true' + with: + name: health + path: ${{ runner.temp }}/health + - name: Create, update or close the issue + env: + GH_TOKEN: ${{ github.token }} + FINDINGS: ${{ needs.check.outputs.findings }} + REPORT: ${{ runner.temp }}/health/health.md + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + number=$(gh issue list --repo "$GITHUB_REPOSITORY" --label health --author "github-actions[bot]" --state open \ + --json number --jq '.[0].number // empty') + if [ "$FINDINGS" = "true" ]; then + printf '\n[Workflow run](%s)\n' "$RUN_URL" >> "$REPORT" + if [ -n "$number" ]; then + gh issue edit "$number" --repo "$GITHUB_REPOSITORY" --body-file "$REPORT" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "Weekly health check" --label health --body-file "$REPORT" + fi + elif [ -n "$number" ]; then + gh issue close "$number" --repo "$GITHUB_REPOSITORY" --comment "Every active entry passed the check in $RUN_URL." + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f83faec --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,105 @@ +name: Publish + +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: publish + cancel-in-progress: false + +jobs: + build: + name: Build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + outputs: + changed: ${{ steps.build.outputs.changed }} + tag: ${{ steps.build.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - run: python -m pip install --require-hashes -r requirements.txt + - name: Build and compare with the latest release + id: build + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="v1.$(date -u +%Y%m%d).${GITHUB_RUN_NUMBER}" + python scripts/build.py --out build/dist --release "$tag" + latest=$(gh release list --repo "$GITHUB_REPOSITORY" --json tagName,isLatest --jq '.[] | select(.isLatest) | .tagName') + previous="" + if [ -n "$latest" ]; then + gh release download "$latest" --repo "$GITHUB_REPOSITORY" --pattern index.json --pattern SHA256SUMS --dir "$RUNNER_TEMP/latest" + previous=$(jq -r .contentSha256 "$RUNNER_TEMP/latest/index.json") + fi + if [ "$(jq -r .contentSha256 build/dist/index.json)" = "$previous" ]; then + # Same data, so Pages keeps serving the files of the latest release. + cp "$RUNNER_TEMP/latest/index.json" "$RUNNER_TEMP/latest/SHA256SUMS" build/dist/ + (cd build/dist && sha256sum --check --strict --quiet SHA256SUMS) + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No data change since $latest, so no release was made." >> "$GITHUB_STEP_SUMMARY" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + fi + mkdir -p build/site/v1 build/site/schema/v1 + cp build/dist/* build/site/v1/ + cp schema/*.json build/site/schema/v1/ + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.build.outputs.changed == 'true' + with: + name: release + path: build/dist/ + if-no-files-found: error + retention-days: 7 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: build/site/ + + release: + name: Release + needs: build + if: needs.build.outputs.changed == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release + path: ${{ runner.temp }}/release + - name: Create the immutable release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.build.outputs.tag }} + ASSETS: ${{ runner.temp }}/release + run: | + (cd "$ASSETS" && sha256sum --check --strict --quiet SHA256SUMS) + gh release create "$TAG" "$ASSETS"/* --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ + --title "$TAG" --notes "Registry data built from ${GITHUB_SHA}." --latest + + pages: + name: Pages + needs: [build, release] + if: ${{ !cancelled() && needs.build.result == 'success' && needs.release.result != 'failure' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - id: deploy + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f0528a4..e7c27ac 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -26,7 +26,7 @@ jobs: with: python-version: "3.14" - run: python -m pip install --require-hashes -r requirements.txt - - run: python -W error scripts/tests/test_validate.py + - run: python -W error -m unittest discover --start-directory scripts/tests entries: name: Entries diff --git a/README.md b/README.md index 4269fdb..71157da 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To list your integration, read [CONTRIBUTING.md](CONTRIBUTING.md). | `integrations//` | `integration.json`, `icon.png`, optional `screenshot-1.png` to `screenshot-4.png` | | `schema/` | JSON Schema 2020-12 for manifests, curation files and the published index | | `curation/` | Maintainer files: official publishers, partners, reserved names, removed entries, protected icons, accepted scan hits | -| `scripts/` | `validate.py`, the shared `registry.py`, and their tests | +| `scripts/` | `validate.py`, `build.py` (the published data), `health.py` (the weekly check), the shared `registry.py`, and their tests | | `_owner/` | Repository settings and rulesets, applied once by an org owner | ## License diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..b6af367 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import io +import json +import shutil +import sys +from pathlib import Path +from typing import Optional + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import registry as r # noqa: E402 +import validate # noqa: E402 + +ICON_SIZES = (512, 128) +SOURCE_ONLY = ("$schema", "schemaVersion") +NOT_CONTENT = ("generatedAt", "commit", "release", "contentSha256") + + +def encode_png(source: Path, size: tuple) -> bytes: + from PIL import Image, ImageCms + + with Image.open(source) as opened: + opened.load() + profile = opened.info.get("icc_profile") + image = opened.convert("RGBA") + if profile: + icc = ImageCms.ImageCmsProfile(io.BytesIO(profile)) + # The profile is not kept, and an untagged PNG is shown as sRGB. + if icc.profile.xcolor_space.strip() == "RGB": + alpha = image.getchannel("A") + image = ImageCms.profileToProfile(image.convert("RGB"), icc, ImageCms.createProfile("sRGB"), outputMode="RGB") + image.putalpha(alpha) + if image.size != size: + image = image.resize(size, Image.Resampling.LANCZOS) + if image.getchannel("A").getextrema() == (255, 255): + image = image.convert("RGB") + clean = Image.frombytes(image.mode, image.size, image.tobytes()) + out = io.BytesIO() + clean.save(out, format="PNG", optimize=True) + return out.getvalue() + + +def write_image(out: Path, name: str, data: bytes, size: tuple) -> dict: + digest = hashlib.sha256(data).hexdigest() + file = f"{name}-{digest[:12]}.png" + (out / file).write_bytes(data) + return {"file": file, "width": size[0], "height": size[1], "sha256": digest} + + +def build_entry(entry: r.Entry, out: Path) -> dict: + manifest = entry.manifest + data = {k: v for k, v in manifest.items() if k not in SOURCE_ONLY} + data["summarySha256"] = hashlib.sha256(manifest["summary"].encode("utf-8")).hexdigest() + data["closedSource"] = manifest["source"]["type"] == "closed" + data["license"] = manifest.get("license") + url = r.source_url(manifest["source"]) + if url: + data["source"] = dict(manifest["source"], url=url) + data["install"] = dict(manifest["install"], url=r.install_url(manifest["install"])) + icon = entry.directory / "icon.png" + data["icon"] = { + str(size): write_image(out, f"{entry.slug}-icon-{size}", encode_png(icon, (size, size)), (size, size)) + for size in ICON_SIZES + } + data["screenshots"] = [ + dict(write_image(out, f"{entry.slug}-{shot['file'][:-4]}", encode_png(entry.directory / shot["file"], r.SHOT_SIZE), + r.SHOT_SIZE), alt=shot["alt"]) + for shot in manifest.get("screenshots") or [] + ] + return data + + +def content_digest(index: dict) -> str: + content = {k: v for k, v in index.items() if k not in NOT_CONTENT} + text = json.dumps(content, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def fail(findings: list, reason: str) -> None: + for finding in findings: + print(f"error: {finding.file}: {finding.message}", file=sys.stderr) + raise SystemExit(f"{reason}; nothing was built") + + +def build(root: Path, out: Path, commit: str, generated_at: str, release: Optional[str] = None) -> dict: + if out.exists() and any(out.iterdir()): + raise SystemExit(f"{out} is not empty") + ctx = validate.run(root, dt.date.fromisoformat(generated_at[:10])) + if ctx.report.errors: + fail(ctx.report.errors, "the registry does not validate") + out.mkdir(parents=True, exist_ok=True) + try: + return write(root, out, ctx, commit, generated_at, release) + except BaseException: + shutil.rmtree(out) + raise + + +def write(root: Path, out: Path, ctx: validate.Context, commit: str, generated_at: str, release: Optional[str]) -> dict: + index = {"schemaVersion": 1, "generatedAt": generated_at, "commit": commit} + if release: + index["release"] = release + index["contentSha256"] = "" + index["integrations"] = [build_entry(entry, out) for _, entry in sorted(ctx.entries.items())] + index["removed"] = [{"slug": row["slug"], "removedAt": row["removedAt"], "reason": row["reason"]} + for row in ctx.curated("removed.json", "removed")] + index["contentSha256"] = content_digest(index) + report = r.Report() + r.schema_errors(r.validator_for(root, r.INDEX_SCHEMA), index, "", "index.json", report) + if report.errors: + fail(report.errors, "index.json does not match schema/index.schema.json") + (out / "index.json").write_bytes((json.dumps(index, indent=2, ensure_ascii=False) + "\n").encode("utf-8")) + names = sorted(p.name for p in out.iterdir()) + sums = "".join(f"{hashlib.sha256((out / name).read_bytes()).hexdigest()} {name}\n" for name in names) + (out / "SHA256SUMS").write_bytes(sums.encode("ascii")) + return index + + +def main(argv: Optional[list] = None) -> int: + parser = argparse.ArgumentParser(description="Build index.json, its images and SHA256SUMS for a release and Pages.") + parser.add_argument("--root", default=str(Path(__file__).resolve().parent.parent)) + parser.add_argument("--out", default="build/dist", help="an empty or missing directory") + parser.add_argument("--release", help="release tag, v1.YYYYMMDD.N") + args = parser.parse_args(argv) + root = Path(args.root) + commit = validate.git(root, "rev-parse", "HEAD").strip() + committed = dt.datetime.fromisoformat(validate.git(root, "log", "-1", "--format=%cI").strip()) + generated_at = committed.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + index = build(root, Path(args.out), commit, generated_at, args.release) + print(f"{len(index['integrations'])} entries, {len(index['removed'])} removed, contentSha256 {index['contentSha256']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/health.py b/scripts/health.py new file mode 100644 index 0000000..05bcd80 --- /dev/null +++ b/scripts/health.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import json +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Callable, Optional + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import registry as r # noqa: E402 +import validate # noqa: E402 + +STALE_PUSH_DAYS = 548 +VERIFY_AGE_DAYS = 365 +GRACE_NOTICE_DAYS = 14 +NEW_DOMAIN_DAYS = 90 +RETRY_SECONDS = 30 +ISSUE_BODY_LIMIT = 60000 +CLIENTS_REPO = "TableProApp/TablePro" +CLIENTS_FILE = "TablePro/Views/Settings/Components/IntegrationClient.swift" +ENUM_CASE = re.compile(r"^[ \t]*case[ \t]+([a-z][A-Za-z0-9]*(?:[ \t]*,[ \t]*[a-z][A-Za-z0-9]*)*)[ \t]*(?:=[^\n]*)?$", re.M) +# Domains that many unrelated entries share; their age says nothing about one entry. +SHARED_DOMAINS = {"github.com", "github.io", "gitlab.com", "visualstudio.com", "open-vsx.org", "raycast.com", + "apple.com", "alfred.app", "brew.sh", "tablepro.app"} +PLAIN_URL_INSTALLS = {"built-in", "ships-with-host", "page"} + + +def registered_on(host: str) -> Optional[dt.date]: + domain = ".".join(host.split(".")[-2:]) + request = urllib.request.Request(f"https://rdap.org/domain/{urllib.parse.quote(domain)}") + request.add_header("Accept", "application/rdap+json") + request.add_header("User-Agent", "tablepro-integrations-health") + try: + with urllib.request.urlopen(request, timeout=30) as response: + data = json.loads(response.read()) + except urllib.error.HTTPError as exc: + exc.close() + return None + except (urllib.error.URLError, TimeoutError, OSError, ValueError): + return None + for event in data.get("events") or []: + if event.get("eventAction") == "registration": + return r.parse_date(str(event.get("eventDate", ""))[:10]) + return None + + +class Health: + def __init__(self, root: Path, today: dt.date, gh: r.GitHub, wait: Callable[[], None]): + self.today = today + self.gh = gh + self.wait = wait + self.findings = r.Report() + curation = r.load_curation(root, r.Report()) + entries = {slug: entry for slug, entry in r.load_entries(root).items() + if isinstance(entry.manifest, dict) and (entry.manifest.get("status") or {}).get("state") == "active"} + self.ctx = validate.Context(root, today, curation, entries, r.Report(), None, "", True) + # Every entry is already listed, so the validator's checks for new entries do not apply. + self.ctx.base_entries = {slug: entry.manifest for slug, entry in entries.items()} + + def add(self, where: str, message: str) -> None: + self.findings.warning(where, message) + + def twice(self, slug: str, check: Callable[[], None]) -> None: + for attempt in (1, 2): + self.ctx.report = r.Report() + check() + if not self.ctx.report.errors: + return + if attempt == 1: + self.wait() + for finding in self.ctx.report.errors: + self.add(slug, finding.message) + + def check_source(self, entry: r.Entry) -> None: + source = entry.manifest["source"] + if source["type"] != "github": + return + self.twice(entry.slug, lambda: validate.check_github_source(self.ctx, self.gh, entry, source, entry.manifest)) + if source.get("path"): + return + status, repo = self.gh.repository(source["repoId"]) + pushed = r.parse_date(str((repo or {}).get("pushed_at") or "")[:10]) if status == 200 else None + if pushed and (self.today - pushed).days > STALE_PUSH_DAYS: + self.add(entry.slug, f"{repo['full_name']} has had no push since {pushed.isoformat()}") + + def check_install(self, entry: r.Entry) -> None: + install = entry.manifest["install"] + + def check() -> None: + validate.check_install_target(self.ctx, entry, entry.manifest) + if install["type"] in PLAIN_URL_INSTALLS: + url = r.install_url(install) + status = r.http_status(url) + if status != 200: + self.ctx.report.error(entry.manifest_path, f"install target {url} answered HTTP {status}") + + self.twice(entry.slug, check) + + def check_links(self, entry: r.Entry) -> None: + target = r.install_url(entry.manifest["install"]) + for url in sorted(set(r.collect_urls(entry.manifest)) - {target}): + def check(url: str = url) -> None: + status = r.http_status(url) + if status not in (200, 429): + self.ctx.report.error(entry.manifest_path, f"{url} answered HTTP {status}") + + self.twice(entry.slug, check) + + def check_domain(self, entry: r.Entry) -> None: + host = urllib.parse.urlsplit(entry.manifest["links"]["homepage"]).hostname or "" + if not host or ".".join(host.split(".")[-2:]) in SHARED_DOMAINS: + return + registered = registered_on(host) + added = r.parse_date(entry.manifest["addedAt"]) + if registered and added and registered > added: + self.add(entry.slug, f"{host} was registered on {registered.isoformat()}, after the entry was added: check who owns it now") + elif registered and (self.today - registered).days < NEW_DOMAIN_DAYS: + self.add(entry.slug, f"{host} was registered on {registered.isoformat()}") + + def check_dates(self, entry: r.Entry) -> None: + grace = (entry.manifest.get("compliance") or {}).get("grace") + if grace: + ends = r.parse_date(grace["endsAt"]) + items = ", ".join(grace["items"]) + if ends and ends < self.today: + self.add(entry.slug, f"grace for {items} ended on {ends.isoformat()}: archive the entry or close {grace['tracking']}") + elif ends and (ends - self.today).days <= GRACE_NOTICE_DAYS: + self.add(entry.slug, f"grace for {items} ends on {ends.isoformat()}: {grace['tracking']}") + verified = r.parse_date(entry.manifest["lastVerifiedAt"]) + if verified and (self.today - verified).days > VERIFY_AGE_DAYS: + self.add(entry.slug, f"last verified on {verified.isoformat()}") + + def check_clients(self) -> None: + status, data = self.gh.contents(CLIENTS_REPO, CLIENTS_FILE) + if status != 200 or not isinstance(data, dict) or "content" not in data: + self.add("TablePro", f"could not read {CLIENTS_REPO}/{CLIENTS_FILE} (HTTP {status})") + return + text = base64.b64decode(data["content"]).decode("utf-8") + offered = {re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name.strip()).lower() + for group in ENUM_CASE.findall(text) for name in group.split(",")} + listed = {slug for slug, entry in self.ctx.entries.items() + if entry.manifest["install"]["type"] == "built-in" and "ai-clients" in entry.manifest["categories"]} + for slug in sorted(offered - listed): + self.add("TablePro", f"Connect a Client offers {slug}, which has no built-in entry") + for slug in sorted(listed - offered): + self.add(slug, f"{CLIENTS_FILE} no longer has this client") + + def scan(self) -> None: + self.ctx.report = r.Report() + validate.scan_sources(self.ctx, sorted(self.ctx.entries)) + for finding in self.ctx.report.findings: + self.add(finding.file.split("/")[1], finding.message) + + def run(self, scan: bool) -> r.Report: + for _, entry in sorted(self.ctx.entries.items()): + self.check_source(entry) + self.check_install(entry) + self.check_links(entry) + self.check_domain(entry) + self.check_dates(entry) + self.check_clients() + if scan: + self.scan() + return self.findings + + +def markdown(findings: r.Report, today: dt.date) -> str: + if not findings.findings: + return "" + body = f"Health check of {today.isoformat()}. It changes no entry; a maintainer decides what to do.\n\n" + rows = sorted(findings.findings, key=lambda f: (f.file, f.message)) + for index, finding in enumerate(rows): + # Code spans keep third-party text from mentioning users or linking issues. + message = finding.message.replace("`", "'") + line = f"- `{finding.file}`: `{message}`\n" + if len(body) + len(line) > ISSUE_BODY_LIMIT: + body += f"\n{len(rows) - index} more findings are in the workflow log.\n" + break + body += line + return body + + +def main(argv: Optional[list] = None) -> int: + parser = argparse.ArgumentParser(description="Weekly health check. Writes a report and never changes an entry.") + parser.add_argument("--root", default=str(Path(__file__).resolve().parent.parent)) + parser.add_argument("--out", default="build/health.md", help="empty when every entry passes") + parser.add_argument("--scan-sources", action="store_true", help="clone each source and scan for private-file access") + parser.add_argument("--today", help="override today's date (YYYY-MM-DD)") + args = parser.parse_args(argv) + today = dt.date.fromisoformat(args.today) if args.today else dt.datetime.now(dt.timezone.utc).date() + findings = Health(Path(args.root), today, r.GitHub(), lambda: time.sleep(RETRY_SECONDS)).run(args.scan_sources) + for finding in findings.findings: + print(f"{finding.file}: {finding.message}") + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(markdown(findings, today), encoding="utf-8") + print(f"{len(findings.findings)} findings") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_build.py b/scripts/tests/test_build.py new file mode 100644 index 0000000..a7b12a5 --- /dev/null +++ b/scripts/tests/test_build.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import sys +import unittest +from pathlib import Path + +TESTS = Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS.parent)) +sys.path.insert(0, str(TESTS)) + +import build # noqa: E402 +import registry as r # noqa: E402 +from test_validate import RepoCase # noqa: E402 + +STAMP = "2026-10-10T08:00:00Z" +COMMIT = "0123456789abcdef0123456789abcdef01234567" +METADATA_CHUNKS = {"iCCP", "sRGB", "gAMA", "cHRM", "tEXt", "zTXt", "iTXt", "eXIf", "pHYs", "tIME"} + + +def save_png(path: Path, size: tuple, color: tuple, **params) -> None: + from PIL import Image + + Image.new("RGBA", size, color).save(path, format="PNG", **params) + + +class BuildTests(RepoCase): + def build(self, out="dist", release=None): + return build.build(self.root, self.root / out, COMMIT, STAMP, release) + + def assertBuildsNothing(self, release=None): + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + self.build(release=release) + self.assertFalse((self.root / "dist").exists()) + + def files(self, out="dist") -> dict: + return {p.name: p.read_bytes() for p in (self.root / out).iterdir()} + + def test_index_matches_the_published_schema(self): + index = self.build() + validator = r.validator_for(self.root, r.INDEX_SCHEMA) + self.assertEqual([e.message for e in validator.iter_errors(index)], []) + self.assertEqual(json.loads((self.root / "dist" / "index.json").read_text(encoding="utf-8")), index) + + def test_two_builds_are_byte_identical(self): + self.build("a") + self.build("b") + self.assertEqual(self.files("a"), self.files("b")) + + def test_release_tag_does_not_change_the_content_digest(self): + plain = self.build("a") + tagged = self.build("b", "v1.20261010.7") + self.assertEqual(plain["contentSha256"], tagged["contentSha256"]) + self.assertEqual(tagged["release"], "v1.20261010.7") + self.assertNotIn("release", plain) + + def test_checksums_list_index_and_every_image(self): + index = self.build() + files = self.files() + listed = {} + for line in files["SHA256SUMS"].decode("ascii").splitlines(): + digest, name = line.split(" ", 1) + listed[name] = digest + self.assertEqual(set(listed), set(files) - {"SHA256SUMS"}) + for name, digest in listed.items(): + self.assertEqual(hashlib.sha256(files[name]).hexdigest(), digest, name) + named = {image["file"] for entry in index["integrations"] for image in entry["icon"].values()} + self.assertEqual(set(listed), named | {"index.json"}) + + def test_images_are_flat_reencoded_and_content_addressed(self): + from PIL import Image, ImageCms + from PIL.PngImagePlugin import PngInfo + + ddev = self.root / "integrations" / "ddev" + text = PngInfo() + text.add_text("Comment", "drawn by hand") + srgb = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes() + save_png(ddev / "icon.png", (512, 512), (10, 120, 200, 255), pnginfo=text, icc_profile=srgb, dpi=(144, 144)) + save_png(ddev / "screenshot-1.png", (1600, 1000), (240, 240, 240, 255)) + self.edit("ddev", lambda d: d.update(screenshots=[{"file": "screenshot-1.png", "alt": "The ddev tablepro command"}])) + entry = {e["slug"]: e for e in self.build()["integrations"]}["ddev"] + images = list(entry["icon"].items()) + [("shot", shot) for shot in entry["screenshots"]] + self.assertEqual(entry["screenshots"][0]["alt"], "The ddev tablepro command") + for label, image in images: + path = self.root / "dist" / image["file"] + data = path.read_bytes() + digest = hashlib.sha256(data).hexdigest() + self.assertEqual(image["sha256"], digest) + self.assertRegex(image["file"], rf"^ddev-[a-z0-9-]+-{digest[:12]}\.png$") + self.assertFalse(METADATA_CHUNKS & set(r.png_chunks(data)), label) + with Image.open(path) as decoded: + self.assertEqual(decoded.size, (image["width"], image["height"])) + self.assertEqual(decoded.mode, "RGB", "an opaque image is stored without alpha") + self.assertEqual([entry["icon"]["512"]["width"], entry["icon"]["128"]["width"]], [512, 128]) + self.assertRegex(entry["screenshots"][0]["file"], r"^ddev-screenshot-1-") + + def test_derived_fields(self): + entries = {e["slug"]: e for e in self.build()["integrations"]} + self.assertTrue(entries["ddev-manager"]["closedSource"]) + self.assertIsNone(entries["ddev-manager"]["license"]) + self.assertEqual(entries["ddev-manager"]["install"]["url"], + "https://marketplace.visualstudio.com/items?itemName=biati.ddev-manager") + self.assertFalse(entries["ddev"]["closedSource"]) + self.assertEqual(entries["ddev"]["source"]["url"], + "https://github.com/ddev/ddev/tree/HEAD/pkg/ddevapp/global_dotddev_assets/commands/host/tablepro") + self.assertEqual(entries["zed"]["install"]["url"], entries["zed"]["install"]["docs"]) + summary = self.manifest("zed")["summary"] + self.assertEqual(entries["zed"]["summarySha256"], hashlib.sha256(summary.encode("utf-8")).hexdigest()) + self.assertNotIn("$schema", entries["zed"]) + + def test_tombstones_are_published(self): + (self.root / "curation" / "removed.json").write_text(json.dumps({ + "$schema": "../schema/curation.schema.json", + "removed": [{"slug": "old-tool", "name": "Old Tool", "removedAt": "2026-10-01", "reason": "author-request", + "link": "https://github.com/TableProApp/integrations/issues/3"}], + })) + index = self.build() + self.assertEqual(index["removed"], [{"slug": "old-tool", "removedAt": "2026-10-01", "reason": "author-request"}]) + + def test_invalid_registry_builds_nothing(self): + self.edit("ddev", lambda d: d.update(tier="official")) + self.assertBuildsNothing() + + def test_bad_release_tag_builds_nothing(self): + self.assertBuildsNothing(release="latest") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_health.py b/scripts/tests/test_health.py new file mode 100644 index 0000000..1462e02 --- /dev/null +++ b/scripts/tests/test_health.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import base64 +import datetime as dt +import sys +import unittest +import urllib.error +from pathlib import Path +from unittest import mock + +TESTS = Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS.parent)) +sys.path.insert(0, str(TESTS)) + +import health # noqa: E402 +import registry as r # noqa: E402 +from test_validate import TODAY, FakeResponse, RepoCase # noqa: E402 + +CLIENTS = """enum IntegrationClient: String, CaseIterable, Identifiable, Sendable { + case claudeCode + case claudeDesktop + case cursor + case zed + + var displayName: String { + switch self { + case .claudeCode: return "Claude Code" + case .claudeDesktop: return "Claude Desktop" + case .cursor: return "Cursor" + case .zed: return "Zed" + } + } +} +""" + + +class HealthTests(RepoCase): + def setUp(self): + super().setUp() + self.routes = {} + for entry in r.load_entries(self.root).values(): + source = entry.manifest["source"] + if source["type"] != "github": + continue + self.routes[f"/repositories/{source['repoId']}"] = (200, { + "full_name": source["repo"], "archived": False, "private": False, "fork": False, + "pushed_at": "2026-09-01T10:00:00Z"}) + if source.get("path"): + self.routes[f"/repos/{source['repo']}/contents/{source['path']}"] = (200, []) + else: + self.routes[f"/repos/{source['repo']}/license"] = (200, {"license": {"spdx_id": entry.manifest["license"]}}) + self.clients(CLIENTS) + self.statuses = {} + self.registered = {} + self.waits = 0 + + def clients(self, text): + path = f"/repos/{health.CLIENTS_REPO}/contents/{health.CLIENTS_FILE}" + self.routes[path] = (200, {"content": base64.b64encode(text.encode()).decode()}) + + def opener(self, request, timeout=30): + path = request.full_url.replace("https://api.github.com", "") + status, body = self.routes.get(path, (404, None)) + if status >= 400: + raise urllib.error.HTTPError(request.full_url, status, "x", {}, None) + return FakeResponse(status, body) + + def http_status(self, url, method="GET", opener=None): + queue = self.statuses.get(url) + return queue.pop(0) if queue else 200 + + def wait(self): + self.waits += 1 + + def check(self): + gh = r.GitHub(token="", opener=self.opener) + with mock.patch.object(r, "http_status", self.http_status), \ + mock.patch.object(health, "registered_on", lambda host: self.registered.get(host)): + report = health.Health(self.root, TODAY, gh, self.wait).run(scan=False) + return [(f.file, f.message) for f in report.findings] + + def assertFinding(self, findings, where, fragment): + self.assertTrue(any(w == where and fragment in m for w, m in findings), f"no {where} finding with {fragment!r}: {findings}") + + def test_healthy_registry_has_no_findings(self): + self.assertEqual(self.check(), []) + self.assertEqual(self.waits, 0) + + def test_install_target_failing_once_is_not_reported(self): + self.statuses["https://www.raycast.com/ngoquocdat/tablepro"] = [503] + self.assertEqual(self.check(), []) + self.assertEqual(self.waits, 1) + + def test_install_target_failing_twice_is_reported(self): + docs = self.manifest("zed")["install"]["docs"] + self.statuses[docs] = [404, 404] + self.assertFinding(self.check(), "zed", f"install target {docs} answered HTTP 404") + + def test_broken_link_is_reported(self): + self.statuses["https://github.com/electricarts/local-tablepro/issues"] = [500, 410] + self.assertFinding(self.check(), "localwp", "answered HTTP 410") + + def test_source_is_checked_by_repository_id(self): + self.routes["/repositories/1309032022"] = (200, {"full_name": "someone/local-tablepro", "archived": True}) + findings = self.check() + self.assertFinding(findings, "localwp", "is someone/local-tablepro, not electricarts/local-tablepro") + self.assertFinding(findings, "localwp", "archived on GitHub") + + def test_deleted_source_is_reported(self): + del self.routes["/repositories/80669528"] + self.assertFinding(self.check(), "ddev", "repository id 80669528 not found") + + def test_removed_license_is_reported(self): + del self.routes["/repos/electricarts/local-tablepro/license"] + self.assertFinding(self.check(), "localwp", "no LICENSE file") + + def test_source_without_recent_push_is_reported(self): + self.routes["/repositories/1309032022"][1]["pushed_at"] = "2024-12-31T00:00:00Z" + self.assertFinding(self.check(), "localwp", "no push since 2024-12-31") + + def test_grace_ending_and_ended(self): + def grace(ends): + self.edit("localwp", lambda d: d["compliance"]["grace"].update( + startedAt=(ends - dt.timedelta(days=60)).isoformat(), endsAt=ends.isoformat())) + grace(TODAY + dt.timedelta(days=10)) + self.assertFinding(self.check(), "localwp", "ends on 2026-10-20") + grace(TODAY - dt.timedelta(days=1)) + self.assertFinding(self.check(), "localwp", "ended on 2026-10-09") + + def test_old_verification_is_reported(self): + self.edit("ddev", lambda d: d.update(addedAt="2025-01-01", lastVerifiedAt="2025-06-01")) + self.assertFinding(self.check(), "ddev", "last verified on 2025-06-01") + + def test_homepage_domain_registered_after_the_entry(self): + self.edit("ddev", lambda d: d.update(addedAt="2026-01-05")) + self.registered["ddev.com"] = dt.date(2026, 3, 1) + self.assertFinding(self.check(), "ddev", "ddev.com was registered on 2026-03-01, after the entry was added") + + def test_new_client_in_the_app_is_reported(self): + self.clients(CLIENTS.replace(" case zed\n", " case zed\n case vsCode\n")) + self.assertFinding(self.check(), "TablePro", "offers vs-code, which has no built-in entry") + + def test_client_dropped_by_the_app_is_reported(self): + self.clients(CLIENTS.replace(" case zed\n", "")) + self.assertFinding(self.check(), "zed", "no longer has this client") + + def test_archived_entries_are_skipped(self): + self.edit("raycast", lambda d: d.update(status={"state": "archived", "since": "2026-10-10", "reason": "unmaintained"})) + self.statuses["https://www.raycast.com/ngoquocdat/tablepro"] = [404, 404] + self.assertEqual(self.check(), []) + + def test_markdown_puts_findings_in_code_spans(self): + report = r.Report() + self.assertEqual(health.markdown(report, TODAY), "") + report.warning("ddev", "src/@someone `x` #12 reads it") + self.assertIn("- `ddev`: `src/@someone 'x' #12 reads it`\n", health.markdown(report, TODAY)) + + +if __name__ == "__main__": + unittest.main()