From 89c90d11d80fde63997ca029e14806df0319a541 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 7 Aug 2026 03:27:37 -0500 Subject: Sync bundled man page catalog from man.sr.ht (#7) The More tab's official man-page list was a hardcoded array, updated by hand. Move it to a checked-in Hutch/Resources/man-pages.json (bundled via the synchronized group) loaded by a new ManPageCatalog, with the previous list kept as a built-in fallback. Add scripts/sync_man_pages.py, which re-derives the catalog from the man.sr.ht landing page, and a weekly scheduled workflow that runs it and opens a pull request when the list diverges upstream. The script refuses to write a suspiciously short list so a markup change can't gut the catalog. This first sync also picks up chat.sr.ht, which upstream added since the list was last hand-edited. Tests cover catalog loading, the fallback, and JSON decoding. --- .github/workflows/sync-man-pages.yml | 55 +++++++++++++++++++++++++++ Hutch/Resources/man-pages.json | 50 ++++++++++++++++++++++++ Hutch/Views/More/ManPageBrowserView.swift | 19 ++-------- Hutch/Views/More/ManPageCatalog.swift | 43 +++++++++++++++++++++ HutchTests/ManPageCatalogTests.swift | 35 +++++++++++++++++ scripts/sync_man_pages.py | 63 +++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/sync-man-pages.yml create mode 100644 Hutch/Resources/man-pages.json create mode 100644 Hutch/Views/More/ManPageCatalog.swift create mode 100644 HutchTests/ManPageCatalogTests.swift create mode 100755 scripts/sync_man_pages.py diff --git a/.github/workflows/sync-man-pages.yml b/.github/workflows/sync-man-pages.yml new file mode 100644 index 0000000..ea73e26 --- /dev/null +++ b/.github/workflows/sync-man-pages.yml @@ -0,0 +1,55 @@ +name: Sync man pages + +# Keeps Hutch/Resources/man-pages.json — the official man-page list the More tab +# renders — in step with man.sr.ht, so the catalog is never hand-maintained. +# +# scripts/sync_man_pages.py re-derives the list from the man.sr.ht landing page. +# If the result differs from the committed copy, this opens (or updates) a pull +# request with the change. The script refuses to write a suspiciously short list, +# so an upstream markup change can't silently gut the catalog. +# +# Note: PRs opened with the default GITHUB_TOKEN do not themselves trigger other +# workflows (GitHub blocks that to avoid recursion), so the Tests workflow won't +# run on the auto-PR. That's fine here — the change is a bundled JSON resource — +# and a maintainer can push an empty commit to run CI if they ever want it. +on: + schedule: + # Weekly, Mondays 06:00 UTC. Upstream churns rarely; this is frequent enough. + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-man-pages + cancel-in-progress: true + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Regenerate man page catalog + run: python3 scripts/sync_man_pages.py + + - name: Open pull request on change + uses: peter-evans/create-pull-request@v7 + with: + add-paths: Hutch/Resources/man-pages.json + branch: automated/man-page-catalog + delete-branch: true + commit-message: "Sync bundled man page catalog with man.sr.ht" + title: "Sync bundled man page catalog" + body: | + Automated update: the official man-page list on man.sr.ht changed. + + Regenerated by `scripts/sync_man_pages.py`. Review the diff to + `Hutch/Resources/man-pages.json` before merging. + labels: documentation diff --git a/Hutch/Resources/man-pages.json b/Hutch/Resources/man-pages.json new file mode 100644 index 0000000..7adf284 --- /dev/null +++ b/Hutch/Resources/man-pages.json @@ -0,0 +1,50 @@ +[ + { + "title": "builds.sr.ht", + "url": "https://man.sr.ht/builds.sr.ht/" + }, + { + "title": "chat.sr.ht", + "url": "https://man.sr.ht/chat.sr.ht/" + }, + { + "title": "git.sr.ht", + "url": "https://man.sr.ht/git.sr.ht/" + }, + { + "title": "hg.sr.ht", + "url": "https://man.sr.ht/hg.sr.ht/" + }, + { + "title": "hub.sr.ht", + "url": "https://man.sr.ht/hub.sr.ht/" + }, + { + "title": "lists.sr.ht", + "url": "https://man.sr.ht/lists.sr.ht/" + }, + { + "title": "man.sr.ht", + "url": "https://man.sr.ht/man.sr.ht/" + }, + { + "title": "meta.sr.ht", + "url": "https://man.sr.ht/meta.sr.ht/" + }, + { + "title": "paste.sr.ht", + "url": "https://man.sr.ht/paste.sr.ht/" + }, + { + "title": "sr.ht", + "url": "https://man.sr.ht/sr.ht/" + }, + { + "title": "srht.site", + "url": "https://srht.site/" + }, + { + "title": "todo.sr.ht", + "url": "https://man.sr.ht/todo.sr.ht/" + } +] diff --git a/Hutch/Views/More/ManPageBrowserView.swift b/Hutch/Views/More/ManPageBrowserView.swift index c22eafe..575b3ae 100644 --- a/Hutch/Views/More/ManPageBrowserView.swift +++ b/Hutch/Views/More/ManPageBrowserView.swift @@ -1,26 +1,15 @@ import SwiftUI /// Entry point for the man.sr.ht browser in the More tab. -/// Shows a pre-populated list of official sr.ht man pages. +/// Shows the official sr.ht man pages from the bundled catalog, which the +/// scheduled sync workflow keeps current with man.sr.ht. struct ManPageBrowserView: View { - private let officialDocs: [(title: String, url: URL)] = [ - ("sr.ht", URL(string: "https://man.sr.ht/sr.ht/")!), - ("hub.sr.ht", URL(string: "https://man.sr.ht/hub.sr.ht/")!), - ("git.sr.ht", URL(string: "https://man.sr.ht/git.sr.ht/")!), - ("hg.sr.ht", URL(string: "https://man.sr.ht/hg.sr.ht/")!), - ("lists.sr.ht", URL(string: "https://man.sr.ht/lists.sr.ht/")!), - ("todo.sr.ht", URL(string: "https://man.sr.ht/todo.sr.ht/")!), - ("builds.sr.ht", URL(string: "https://man.sr.ht/builds.sr.ht/")!), - ("paste.sr.ht", URL(string: "https://man.sr.ht/paste.sr.ht/")!), - ("man.sr.ht", URL(string: "https://man.sr.ht/man.sr.ht/")!), - ("meta.sr.ht", URL(string: "https://man.sr.ht/meta.sr.ht/")!), - ("srht.site", URL(string: "https://srht.site/")!) - ] + private let officialDocs = ManPageCatalog.load() var body: some View { List { Section("Official Man Pages") { - ForEach(officialDocs, id: \.title) { doc in + ForEach(officialDocs) { doc in NavigationLink(value: MoreRoute.manPage(doc.url)) { Text(doc.title) } diff --git a/Hutch/Views/More/ManPageCatalog.swift b/Hutch/Views/More/ManPageCatalog.swift new file mode 100644 index 0000000..faff0c5 --- /dev/null +++ b/Hutch/Views/More/ManPageCatalog.swift @@ -0,0 +1,43 @@ +import Foundation + +struct ManPageCatalogEntry: Decodable, Hashable, Identifiable { + let title: String + let url: URL + + var id: String { title } +} + +enum ManPageCatalog { + /// Official sr.ht man pages, loaded from the bundled `man-pages.json` that + /// the scheduled sync workflow keeps in step with man.sr.ht. Falls back to a + /// built-in list if the resource is missing or unreadable, so the browser is + /// never empty. + static func load(bundle: Bundle = .main) -> [ManPageCatalogEntry] { + guard let url = bundle.url(forResource: "man-pages", withExtension: "json"), + let data = try? Data(contentsOf: url), + let entries = try? JSONDecoder().decode([ManPageCatalogEntry].self, from: data), + !entries.isEmpty else { + return fallback + } + return entries + } + + static let fallback: [ManPageCatalogEntry] = [ + entry("builds.sr.ht", "https://man.sr.ht/builds.sr.ht/"), + entry("chat.sr.ht", "https://man.sr.ht/chat.sr.ht/"), + entry("git.sr.ht", "https://man.sr.ht/git.sr.ht/"), + entry("hg.sr.ht", "https://man.sr.ht/hg.sr.ht/"), + entry("hub.sr.ht", "https://man.sr.ht/hub.sr.ht/"), + entry("lists.sr.ht", "https://man.sr.ht/lists.sr.ht/"), + entry("man.sr.ht", "https://man.sr.ht/man.sr.ht/"), + entry("meta.sr.ht", "https://man.sr.ht/meta.sr.ht/"), + entry("paste.sr.ht", "https://man.sr.ht/paste.sr.ht/"), + entry("sr.ht", "https://man.sr.ht/sr.ht/"), + entry("srht.site", "https://srht.site/"), + entry("todo.sr.ht", "https://man.sr.ht/todo.sr.ht/") + ] + + private static func entry(_ title: String, _ urlString: String) -> ManPageCatalogEntry { + ManPageCatalogEntry(title: title, url: URL(string: urlString)!) + } +} diff --git a/HutchTests/ManPageCatalogTests.swift b/HutchTests/ManPageCatalogTests.swift new file mode 100644 index 0000000..df995f8 --- /dev/null +++ b/HutchTests/ManPageCatalogTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import Hutch + +struct ManPageCatalogTests { + @Test + func loadsBundledCatalog() { + let entries = ManPageCatalog.load() + #expect(!entries.isEmpty) + #expect(entries.contains { $0.title == "git.sr.ht" }) + #expect(entries.allSatisfy { $0.url.scheme == "https" }) + } + + @Test + func fallsBackWhenResourceMissing() { + // Foundation's own bundle has no man-pages.json, so this exercises the + // fallback path rather than the bundled resource. + let entries = ManPageCatalog.load(bundle: Bundle(for: JSONDecoder.self)) + #expect(entries == ManPageCatalog.fallback) + } + + @Test + func decodesCatalogJSON() throws { + let json = """ + [ + { "title": "git.sr.ht", "url": "https://man.sr.ht/git.sr.ht/" }, + { "title": "srht.site", "url": "https://srht.site/" } + ] + """ + let entries = try JSONDecoder().decode([ManPageCatalogEntry].self, from: Data(json.utf8)) + #expect(entries.count == 2) + #expect(entries.first?.title == "git.sr.ht") + #expect(entries.first?.url == URL(string: "https://man.sr.ht/git.sr.ht/")) + } +} diff --git a/scripts/sync_man_pages.py b/scripts/sync_man_pages.py new file mode 100755 index 0000000..c59197a --- /dev/null +++ b/scripts/sync_man_pages.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Regenerate the bundled sr.ht man page catalog from man.sr.ht. + +Fetches the man.sr.ht landing page, extracts the per-service "User Manual" +links, and writes them to ``Hutch/Resources/man-pages.json``. The scheduled +GitHub workflow that runs this opens a pull request whenever the result differs +from the committed copy, so the in-app list stays in sync with upstream without +hand edits. + +Run locally with ``python3 scripts/sync_man_pages.py``; exits non-zero (without +writing) if upstream markup changed enough that too few pages were found, so a +bad scrape can never wipe the bundled list. +""" +import json +import re +import sys +import urllib.request +from pathlib import Path + +INDEX_URL = "https://man.sr.ht/" +OUTPUT = Path(__file__).resolve().parent.parent / "Hutch" / "Resources" / "man-pages.json" +# The suite has ~12 service manuals; a scrape returning far fewer means the page +# structure changed and we should fail loudly rather than commit a gutted list. +MINIMUM_EXPECTED = 8 + + +def fetch(url: str) -> str: + request = urllib.request.Request(url, headers={"User-Agent": "hutch-man-page-sync"}) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read().decode("utf-8") + + +def build_catalog(html: str) -> list[dict[str, str]]: + """Extract official man-page links, deduplicated and sorted by title.""" + entries: dict[str, str] = {} + for href in re.findall(r'href="([^"]+)"', html): + service = re.fullmatch(r"/([a-z0-9][a-z0-9.-]*\.sr\.ht)/?", href) + if service: + title = service.group(1) + entries[title] = f"https://man.sr.ht/{title}/" + elif re.fullmatch(r"sr\.ht/?", href): + entries["sr.ht"] = "https://man.sr.ht/sr.ht/" + elif re.fullmatch(r"https://srht\.site/?", href): + entries["srht.site"] = "https://srht.site/" + return [{"title": title, "url": entries[title]} for title in sorted(entries)] + + +def main() -> int: + catalog = build_catalog(fetch(INDEX_URL)) + if len(catalog) < MINIMUM_EXPECTED: + print( + f"Refusing to write catalog with only {len(catalog)} entries; " + "man.sr.ht markup may have changed.", + file=sys.stderr, + ) + return 1 + OUTPUT.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n") + print(f"Wrote {len(catalog)} man page(s) to {OUTPUT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3 From f9f86b9681909b14304909b1286b9b50362625d7 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 7 Aug 2026 03:38:40 -0500 Subject: Pin create-pull-request action to a commit SHA SonarCloud (githubactions:S7637) flags third-party actions referenced by mutable tag as a supply-chain risk; pin to the v7 commit SHA. --- .github/workflows/sync-man-pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-man-pages.yml b/.github/workflows/sync-man-pages.yml index ea73e26..46c7701 100644 --- a/.github/workflows/sync-man-pages.yml +++ b/.github/workflows/sync-man-pages.yml @@ -40,7 +40,7 @@ jobs: run: python3 scripts/sync_man_pages.py - name: Open pull request on change - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7 with: add-paths: Hutch/Resources/man-pages.json branch: automated/man-page-catalog -- cgit v1.2.3