pyrtan / src / pyrtan / names.py
25,815 bytes · last changed 2026-10-28 in Key backups, and names any network can run on any git host
| 1 | """Names: ``alice@example.org`` resolved to an identity, pinned, and served. |
| 2 | |
| 3 | A name resolves by fetching ``https://<domain>/.well-known/pyrtan/name/<local>.json``, |
| 4 | a claim signed by the identity it names. The resolver checks that the |
| 5 | claim is for the name it asked and that the signature holds, then |
| 6 | compares the answer with the *pin*: the identity the name resolved to |
| 7 | the first time. A different answer later is never followed; it is shown |
| 8 | beside the pinned one and the person re-pins by hand, as SSH does with a |
| 9 | changed host key. That is what makes a domain's word safe to take. |
| 10 | |
| 11 | Any domain can be a name provider by serving those files, from any |
| 12 | static host or from its own hubs (``pyrtan hub --names DIR``). |
| 13 | :func:`build_wellknown` writes them from a directory of claims (and of |
| 14 | identity documents, served at ``/.well-known/pyrtan/id/<56>.json``); |
| 15 | pyrtan.com's build runs it on ``site/names/``. |
| 16 | |
| 17 | Who may change a name is a directory's own rule. :func:`gate_key`, for any |
| 18 | network on any git host, needs nothing but signatures: a new name is open |
| 19 | to whoever signs it, a name moves to a new key only with a *handover* its |
| 20 | old key signs, it is given up only by a *release* its key signs, and an |
| 21 | identity document is never replaced by an older one or deleted while its |
| 22 | name stands. :func:`gate` is pyrtan.com's own rule: a name is its holder's |
| 23 | GitHub login, added and changed only by the GitHub account whose numeric |
| 24 | id the claim carries, because GitHub frees a login when its owner renames. |
| 25 | |
| 26 | This module never fetches an ``.onion``: that needs Tor, which pyrtan does |
| 27 | not reach yet. The design is ``_meta/identity.md`` §6. |
| 28 | """ |
| 29 | |
| 30 | from __future__ import annotations |
| 31 | |
| 32 | import json |
| 33 | import re |
| 34 | import shutil |
| 35 | import time |
| 36 | from dataclasses import dataclass, field |
| 37 | from pathlib import Path |
| 38 | from typing import Any, Callable |
| 39 | |
| 40 | from . import identity |
| 41 | from .catalog import Problem |
| 42 | from .identity import IdentityError |
| 43 | |
| 44 | WELL_KNOWN = ".well-known/pyrtan" |
| 45 | NAMES_DIR = "site/names" |
| 46 | GITHUB_LOGIN = re.compile(r"^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$") |
| 47 | |
| 48 | Fetch = Callable[[str], tuple[int, Any]] |
| 49 | |
| 50 | |
| 51 | # ---------------------------------------------------------------- resolving |
| 52 | |
| 53 | |
| 54 | def _fetch(url: str) -> tuple[int, Any]: |
| 55 | from .service import http_json |
| 56 | return http_json("GET", url, timeout=10.0) |
| 57 | |
| 58 | |
| 59 | def _base(host: str) -> str: |
| 60 | host = identity.check_locator(host) |
| 61 | if host.split(":")[0].endswith(".onion"): |
| 62 | raise IdentityError(f"{host} is an onion service: reaching it needs Tor, which pyrtan does not reach yet") |
| 63 | return f"https://{host}" |
| 64 | |
| 65 | |
| 66 | def name_url(name: str) -> str: |
| 67 | parsed = identity.parse(name) |
| 68 | if parsed.form != "name": |
| 69 | raise IdentityError(f"{name!r} is not a name (name@domain)") |
| 70 | return f"{_base(parsed.domain)}/{WELL_KNOWN}/name/{parsed.name}.json" |
| 71 | |
| 72 | |
| 73 | def identity_url(ident: str, host: str) -> str: |
| 74 | return f"{_base(host)}/{WELL_KNOWN}/id/{identity.normal(ident)[len(identity.PREFIX):]}.json" |
| 75 | |
| 76 | |
| 77 | def resolve(name: str, *, fetch: Fetch | None = None) -> dict[str, Any]: |
| 78 | """The checked claim ``name``'s domain serves; IdentityError says why there is none.""" |
| 79 | url = name_url(name) |
| 80 | try: |
| 81 | status, claim = (fetch or _fetch)(url) |
| 82 | except Exception as e: |
| 83 | raise IdentityError(f"{url}: {e}") from None |
| 84 | if status == 404: |
| 85 | raise IdentityError(f"{identity.parse(name)} has no claim at {url}") |
| 86 | if status != 200: |
| 87 | raise IdentityError(f"{url} answered {status}") |
| 88 | problem = identity.check_name(claim, name=name) |
| 89 | if problem: |
| 90 | raise IdentityError(f"{url}: {problem}") |
| 91 | return claim |
| 92 | |
| 93 | |
| 94 | def fetch_identity(ident: str, host: str, *, fetch: Fetch | None = None) -> dict[str, Any] | None: |
| 95 | """The identity document ``host`` serves for ``ident``, if it serves one that holds.""" |
| 96 | try: |
| 97 | status, doc = (fetch or _fetch)(identity_url(ident, host)) |
| 98 | except Exception: |
| 99 | return None |
| 100 | if status != 200 or identity.check_identity(doc, ident=ident) is not None: |
| 101 | return None |
| 102 | return doc |
| 103 | |
| 104 | |
| 105 | # ---------------------------------------------------------------- pins |
| 106 | |
| 107 | |
| 108 | class Pins: |
| 109 | """Name -> the identity it first resolved to, in a JSON file.""" |
| 110 | |
| 111 | def __init__(self, path: Path): |
| 112 | self.path = path |
| 113 | self.names: dict[str, dict[str, Any]] = {} |
| 114 | if path.exists(): |
| 115 | try: |
| 116 | data = json.loads(path.read_text(encoding="utf-8")) |
| 117 | except (OSError, json.JSONDecodeError) as e: |
| 118 | raise IdentityError(f"{path}: {e}") from None |
| 119 | if not isinstance(data, dict) or data.get("pyrtan-pins") != 1: |
| 120 | raise IdentityError(f"{path} is not a pyrtan pins file") |
| 121 | self.names = data.get("names", {}) |
| 122 | |
| 123 | def get(self, name: str) -> dict[str, Any] | None: |
| 124 | return self.names.get(str(identity.parse(name))) |
| 125 | |
| 126 | def compare(self, name: str, ident: str) -> str: |
| 127 | """``new``, ``same`` or ``changed``.""" |
| 128 | pin = self.get(name) |
| 129 | return "new" if pin is None else "same" if pin["id"] == identity.normal(ident) else "changed" |
| 130 | |
| 131 | def pin(self, name: str, ident: str, *, now: int | None = None) -> None: |
| 132 | now = int(time.time()) if now is None else now |
| 133 | key, ident = str(identity.parse(name)), identity.normal(ident) |
| 134 | old = self.names.get(key) |
| 135 | first = old["first"] if old and old["id"] == ident else now |
| 136 | self.names[key] = {"id": ident, "first": first, "seen": now} |
| 137 | |
| 138 | def forget(self, name: str) -> bool: |
| 139 | return self.names.pop(str(identity.parse(name)), None) is not None |
| 140 | |
| 141 | def save(self) -> None: |
| 142 | self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) |
| 143 | temp = self.path.with_suffix(".json.tmp") |
| 144 | temp.write_text(json.dumps({"pyrtan-pins": 1, "names": self.names}, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| 145 | temp.replace(self.path) |
| 146 | |
| 147 | |
| 148 | # ---------------------------------------------------------------- a directory of names |
| 149 | |
| 150 | |
| 151 | @dataclass |
| 152 | class Directory: |
| 153 | """What a directory of names holds: claims, identity documents and releases, by name.""" |
| 154 | claims: dict[str, dict[str, Any]] = field(default_factory=dict) |
| 155 | docs: dict[str, dict[str, Any]] = field(default_factory=dict) |
| 156 | releases: dict[str, dict[str, Any]] = field(default_factory=dict) |
| 157 | problems: list[Problem] = field(default_factory=list) |
| 158 | |
| 159 | |
| 160 | def load_directory(directory: Path | None, domain: str, *, label: str | None = None) -> Directory: |
| 161 | """The claims (``<name>.json``), releases (a ``<name>.json`` that gives the name up) and |
| 162 | identity documents (``<name>.id.json``) in ``directory``, for ``domain``. Problems name |
| 163 | files as ``label/<file>`` (default: the directory as given); what fails a check is left out.""" |
| 164 | d = Directory() |
| 165 | if directory is None or not directory.is_dir(): |
| 166 | return d |
| 167 | label = directory.as_posix() if label is None else label |
| 168 | |
| 169 | def rel(p: Path) -> str: |
| 170 | return f"{label}/{p.name}" |
| 171 | |
| 172 | raw_docs: dict[str, Any] = {} |
| 173 | for path in sorted(directory.iterdir()): |
| 174 | if path.name.startswith(".") or path.name == "README.md": |
| 175 | continue |
| 176 | if not path.is_file() or not path.name.endswith(".json"): |
| 177 | d.problems.append(Problem(rel(path), "only <name>.json and <name>.id.json files belong here")) |
| 178 | continue |
| 179 | try: |
| 180 | data = json.loads(path.read_text(encoding="utf-8")) |
| 181 | except (OSError, json.JSONDecodeError) as e: |
| 182 | d.problems.append(Problem(rel(path), f"not JSON: {e}")) |
| 183 | continue |
| 184 | if path.name.endswith(".id.json"): |
| 185 | raw_docs[path.name[: -len(".id.json")]] = data |
| 186 | continue |
| 187 | stem = path.name[: -len(".json")] |
| 188 | if not identity.LOCAL.match(stem): |
| 189 | d.problems.append(Problem(rel(path), f"{stem!r} is not a name")) |
| 190 | continue |
| 191 | if isinstance(data, dict) and "pyrtan-name-release" in data: |
| 192 | problem = identity.check_release(data, name=f"{stem}@{domain}") |
| 193 | target = d.releases |
| 194 | else: |
| 195 | problem = identity.check_name(data, name=f"{stem}@{domain}") |
| 196 | target = d.claims |
| 197 | if problem: |
| 198 | d.problems.append(Problem(rel(path), problem)) |
| 199 | continue |
| 200 | target[stem] = data |
| 201 | for stem, doc in raw_docs.items(): |
| 202 | where = rel(directory / f"{stem}.id.json") |
| 203 | if stem not in d.claims: |
| 204 | d.problems.append(Problem(where, f"an identity document needs its name's claim, {stem}.json, beside it")) |
| 205 | continue |
| 206 | problem = identity.check_identity(doc, ident=d.claims[stem]["id"]) |
| 207 | if problem: |
| 208 | d.problems.append(Problem(where, problem)) |
| 209 | continue |
| 210 | d.docs[stem] = doc |
| 211 | return d |
| 212 | |
| 213 | |
| 214 | def read_directory(directory: Path, domain: str, *, label: str | None = None) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]], list[Problem]]: |
| 215 | """``(claims by name, documents by name, problems)``; see :func:`load_directory`.""" |
| 216 | d = load_directory(directory, domain, label=label) |
| 217 | return d.claims, d.docs, d.problems |
| 218 | |
| 219 | |
| 220 | def gate_key(head: Path, base: Path | None, domain: str, *, label: str = "names") -> list[Problem]: |
| 221 | """The rule for a directory of names owned by their keys, on any git host: ``head`` is the |
| 222 | directory a change proposes, ``base`` the one it changes (None for a first commit). |
| 223 | Needs only the two trees, never who proposed the change.""" |
| 224 | h = load_directory(head, domain, label=label) |
| 225 | b = load_directory(base, domain, label=label) |
| 226 | problems = list(h.problems) |
| 227 | names = sorted(set(h.claims) | set(h.releases) | set(b.claims) | set(b.releases) | set(b.docs)) |
| 228 | for n in names: |
| 229 | where, dwhere = f"{label}/{n}.json", f"{label}/{n}.id.json" |
| 230 | held, hc, hr = b.claims.get(n), h.claims.get(n), h.releases.get(n) |
| 231 | if held is not None: |
| 232 | if hc is not None and hc["id"] != held["id"]: |
| 233 | problem = identity.check_handover(hc, held["id"]) |
| 234 | if problem: |
| 235 | problems.append(Problem(where, problem)) |
| 236 | elif hr is not None and hr["id"] != held["id"]: |
| 237 | problems.append(Problem(where, f"the release is {identity.short(hr['id'])}'s; {n} is held by {identity.short(held['id'])}")) |
| 238 | elif hc is None and hr is None and not (head / f"{n}.json").exists(): |
| 239 | problems.append(Problem(where, f"{n} is given up by replacing its claim with a release its key signs (pyrtan id release), not by deleting it")) |
| 240 | elif hr is not None and n not in b.releases: |
| 241 | problems.append(Problem(where, f"nobody holds {n}; there is nothing to release")) |
| 242 | # An identity document only moves forward while its name stays with the same key. |
| 243 | old = b.docs.get(n) |
| 244 | if old is not None and held is not None and hc is not None and hc["id"] == held["id"]: |
| 245 | new = h.docs.get(n) |
| 246 | if new is None and not (head / f"{n}.id.json").exists(): |
| 247 | problems.append(Problem(dwhere, "an identity document is replaced by a newer one its key signs, not deleted: deleting it would hide its revocations")) |
| 248 | elif new is not None and new["updated"] < old["updated"]: |
| 249 | problems.append(Problem(dwhere, "this document is older than the one it replaces: an older one could bring a revoked device back")) |
| 250 | return problems |
| 251 | |
| 252 | |
| 253 | def build_wellknown(directory: Path, out: Path, domain: str) -> tuple[int, list[Problem]]: |
| 254 | """Write ``out/.well-known/pyrtan/``: ``name/<name>.json`` per claim, ``id/<56>.json`` |
| 255 | per identity document, and ``names.json`` listing every claim. Returns how |
| 256 | many names were written and the problems; nothing that fails a check is served.""" |
| 257 | claims, docs, problems = read_directory(directory, domain) |
| 258 | root = out / WELL_KNOWN |
| 259 | if root.exists(): |
| 260 | shutil.rmtree(root) |
| 261 | (root / "name").mkdir(parents=True) |
| 262 | (root / "id").mkdir() |
| 263 | for stem, claim in claims.items(): |
| 264 | (root / "name" / f"{stem}.json").write_text(json.dumps(claim, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") |
| 265 | newest: dict[str, dict[str, Any]] = {} |
| 266 | for doc in docs.values(): |
| 267 | if doc["id"] not in newest or doc["updated"] > newest[doc["id"]]["updated"]: |
| 268 | newest[doc["id"]] = doc |
| 269 | for ident, doc in newest.items(): |
| 270 | (root / "id" / f"{ident[len(identity.PREFIX):]}.json").write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") |
| 271 | index = {"pyrtan-names": 1, "domain": domain, "names": claims} |
| 272 | (root / "names.json").write_text(json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8") |
| 273 | return len(claims), problems |
| 274 | |
| 275 | |
| 276 | # ---------------------------------------------------------------- pyrtan.com's gate |
| 277 | |
| 278 | |
| 279 | def gate(root: Path, *, changed: list[str] | None = None, author: str | None = None, author_id: int | None = None, |
| 280 | base: dict[str, bytes] | None = None) -> list[Problem]: |
| 281 | """pyrtan.com's rule for ``site/names/``, on top of every claim and document holding: |
| 282 | a name is a GitHub login, at most one per GitHub account (its numeric ``github`` id). |
| 283 | Given the pull request's ``changed`` paths, ``author`` (login), ``author_id`` and |
| 284 | ``base`` (``path -> bytes`` on the base branch), a new name must be the author's own |
| 285 | login and carry the author's id, and a name may be changed or removed only by the |
| 286 | account whose id it carried before.""" |
| 287 | domain = (root / "site" / "CNAME").read_text(encoding="utf-8").strip() if (root / "site" / "CNAME").is_file() else "pyrtan.com" |
| 288 | directory = root / NAMES_DIR |
| 289 | claims, _, problems = read_directory(directory, domain, label=NAMES_DIR) |
| 290 | holders: dict[int, str] = {} |
| 291 | for stem, claim in claims.items(): |
| 292 | where = f"{NAMES_DIR}/{stem}.json" |
| 293 | if not GITHUB_LOGIN.match(stem): |
| 294 | problems.append(Problem(where, "a name at pyrtan.com is a GitHub login")) |
| 295 | github = claim.get("github") |
| 296 | if not isinstance(github, int) or isinstance(github, bool) or github <= 0: |
| 297 | problems.append(Problem(where, "a claim at pyrtan.com carries \"github\": your numeric GitHub user id")) |
| 298 | continue |
| 299 | if github in holders: |
| 300 | problems.append(Problem(where, f"GitHub account {github} already holds {holders[github]}; one name per account")) |
| 301 | holders[github] = stem |
| 302 | if not changed: |
| 303 | return problems |
| 304 | base = base or {} |
| 305 | login = (author or "").lstrip("@").lower() |
| 306 | for c in changed: |
| 307 | if not c.startswith(f"{NAMES_DIR}/") or c.endswith("/README.md"): |
| 308 | continue |
| 309 | name = Path(c).name |
| 310 | stem = name[: -len(".id.json")] if name.endswith(".id.json") else name[: -len(".json")] if name.endswith(".json") else name |
| 311 | claim_path = f"{NAMES_DIR}/{stem}.json" |
| 312 | before = _github_of(base.get(claim_path)) |
| 313 | if author_id is None: |
| 314 | problems.append(Problem(c, "the gate needs the pull request author's numeric id (--author-id) to judge a change to a name")) |
| 315 | continue |
| 316 | if before is not None: |
| 317 | if before != author_id: |
| 318 | problems.append(Problem(c, f"{stem} belongs to GitHub account {before}; only that account changes or removes it (a maintainer may merge over this)")) |
| 319 | elif stem in claims and claims[stem].get("github") != before: |
| 320 | problems.append(Problem(c, f"{stem} stays with GitHub account {before}; to move it, remove it and add your new login's name")) |
| 321 | continue |
| 322 | # A new name. |
| 323 | if stem not in claims: |
| 324 | continue # a stray file, already reported |
| 325 | if stem != login: |
| 326 | problems.append(Problem(c, f"a new name is your own GitHub login: {stem!r} is not {login or 'the author'}'s")) |
| 327 | if claims[stem].get("github") != author_id: |
| 328 | problems.append(Problem(c, f"the claim says github {claims[stem].get('github')}; the pull request's author is {author_id}")) |
| 329 | return problems |
| 330 | |
| 331 | |
| 332 | def _github_of(raw: bytes | None) -> int | None: |
| 333 | if raw is None: |
| 334 | return None |
| 335 | try: |
| 336 | github = json.loads(raw.decode("utf-8")).get("github") |
| 337 | except (ValueError, AttributeError): |
| 338 | return None |
| 339 | return github if isinstance(github, int) and not isinstance(github, bool) else None |
| 340 | |
| 341 | |
| 342 | def place(checkout: Path, claim: dict[str, Any], doc: dict[str, Any] | None) -> list[Path]: |
| 343 | """Write a claim (and the identity document) where ``checkout`` keeps names: a network's |
| 344 | names repository (``pyrtan-names.json`` and ``names/``), or this repository's ``site/names/``.""" |
| 345 | network = repo_domain(checkout) |
| 346 | if network is not None: |
| 347 | directory, domain = checkout / "names", network |
| 348 | elif (checkout / "site").is_dir(): |
| 349 | directory = checkout / NAMES_DIR |
| 350 | domain = (checkout / "site" / "CNAME").read_text(encoding="utf-8").strip() if (checkout / "site" / "CNAME").is_file() else "pyrtan.com" |
| 351 | else: |
| 352 | raise IdentityError(f"{checkout}: neither a names repository (pyrtan names init) nor a checkout of github.com/b-i-t-8/pyrtan") |
| 353 | if claim["domain"] != domain: |
| 354 | raise IdentityError(f"this checkout serves names at {domain}; the claim is for {claim['name']}@{claim['domain']}") |
| 355 | directory.mkdir(parents=True, exist_ok=True) |
| 356 | written = [directory / f"{claim['name']}.json"] |
| 357 | written[0].write_text(json.dumps(claim, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") |
| 358 | if doc is not None: |
| 359 | written.append(directory / f"{claim['name']}.id.json") |
| 360 | written[1].write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") |
| 361 | return written |
| 362 | |
| 363 | |
| 364 | def github_id(login: str, *, fetch: Fetch | None = None) -> int: |
| 365 | """A GitHub login's numeric user id, from GitHub's public API.""" |
| 366 | url = f"https://api.github.com/users/{login}" |
| 367 | try: |
| 368 | status, body = (fetch or _fetch)(url) |
| 369 | except Exception as e: |
| 370 | raise IdentityError(f"{url}: {e}; pass --github YOUR-NUMERIC-ID") from None |
| 371 | if status != 200 or not isinstance(body, dict) or not isinstance(body.get("id"), int): |
| 372 | raise IdentityError(f"{url} answered {status}; pass --github YOUR-NUMERIC-ID") |
| 373 | return body["id"] |
| 374 | |
| 375 | |
| 376 | # ---------------------------------------------------------------- a network's own names repository |
| 377 | |
| 378 | REPO_FILE = "pyrtan-names.json" # at a names repository's root: {"pyrtan-names-repo": 1, "domain": ...} |
| 379 | HOSTS = ("github", "gitlab", "plain") |
| 380 | PYRTAN_GIT = "https://github.com/b-i-t-8/pyrtan" |
| 381 | |
| 382 | _README = """# Names at __DOMAIN__ |
| 383 | |
| 384 | A name here is `<name>@__DOMAIN__`, pointing at a pyrtan identity (a key: |
| 385 | `pyr:` and 56 characters). Anyone resolves it with |
| 386 | |
| 387 | pyrtan id resolve <name>@__DOMAIN__ |
| 388 | |
| 389 | which fetches `https://__DOMAIN__/.well-known/pyrtan/name/<name>.json`, checks |
| 390 | that the key signed it, and pins the answer. The names are the files in |
| 391 | `names/`; they are served by GitHub or GitLab Pages, or by any hub of the |
| 392 | network started `pyrtan hub --names names --names-domain __DOMAIN__`. |
| 393 | |
| 394 | ## Getting one |
| 395 | |
| 396 | In the pyrtan console, Identity tab: *Claim a name*, with this repository and |
| 397 | `__DOMAIN__`. Or from a shell: |
| 398 | |
| 399 | pyrtan id name <name>@__DOMAIN__ --into <a checkout of this repository> |
| 400 | |
| 401 | Then open a pull (or merge) request with `names/<name>.json`, and |
| 402 | `names/<name>.id.json` if you want hubs to find your devices here. |
| 403 | |
| 404 | ## Who may change a name |
| 405 | |
| 406 | Names here are owned by their keys, and the gate checks only signatures: |
| 407 | |
| 408 | - a new name is open to whoever signs it, while nobody holds it; |
| 409 | - a name moves to a new key only with a handover its old key signs |
| 410 | (`pyrtan id name <name>@__DOMAIN__ --handover-from OLD-KEY`); |
| 411 | - a name is given up by replacing its claim with a release its key signs |
| 412 | (`pyrtan id release <name>@__DOMAIN__`), never by deleting it; |
| 413 | - an identity document is replaced only by a newer one its key signs. |
| 414 | |
| 415 | Nothing in this repository is secret: never put an invite, a token or anyone's |
| 416 | key backup here, not even encrypted. |
| 417 | """ |
| 418 | |
| 419 | _GITHUB_GATE = """# Generated by `pyrtan names init`: the names gate for __DOMAIN__. |
| 420 | # |
| 421 | # pull_request_target, so the gate is always this file as it is on the default |
| 422 | # branch, and pyrtan comes from a pinned source, never from the pull request: a |
| 423 | # pull request cannot change what judges it. The pull request's tree is checked |
| 424 | # out as data and compared with its merge base; nothing in it is run. Read-only |
| 425 | # token, no secrets, no credentials left in the checkouts, no cache. |
| 426 | # Pin __REF__ to a commit of pyrtan you have read. |
| 427 | |
| 428 | name: names-gate |
| 429 | |
| 430 | on: |
| 431 | pull_request_target: |
| 432 | paths: ["names/**"] |
| 433 | |
| 434 | permissions: |
| 435 | contents: read |
| 436 | |
| 437 | jobs: |
| 438 | gate: |
| 439 | runs-on: ubuntu-latest |
| 440 | env: |
| 441 | BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 442 | steps: |
| 443 | - uses: actions/checkout@v4 |
| 444 | with: |
| 445 | ref: ${{ github.event.pull_request.base.sha }} |
| 446 | path: base-repo |
| 447 | fetch-depth: 0 |
| 448 | persist-credentials: false |
| 449 | - uses: actions/checkout@v4 |
| 450 | with: |
| 451 | repository: ${{ github.event.pull_request.head.repo.full_name }} |
| 452 | ref: ${{ github.event.pull_request.head.sha }} |
| 453 | path: pr |
| 454 | fetch-depth: 0 |
| 455 | persist-credentials: false |
| 456 | - name: The merge base, as data |
| 457 | run: | |
| 458 | git -C pr fetch --no-tags "$GITHUB_WORKSPACE/base-repo" "$BASE_SHA" |
| 459 | git -C pr worktree add --detach "$GITHUB_WORKSPACE/base" "$(git -C pr merge-base "$BASE_SHA" HEAD)" |
| 460 | - uses: actions/setup-python@v5 |
| 461 | with: |
| 462 | python-version: "3.12" |
| 463 | - run: pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__" |
| 464 | - run: pyrtan names gate pr/names --base base/names --domain __DOMAIN__ |
| 465 | """ |
| 466 | |
| 467 | _GITHUB_PAGES = """# Generated by `pyrtan names init`: serve names/ as https://__DOMAIN__/.well-known/pyrtan/. |
| 468 | # Set the repository's Pages source to "GitHub Actions" and its custom domain to __DOMAIN__. |
| 469 | |
| 470 | name: names-pages |
| 471 | |
| 472 | on: |
| 473 | push: |
| 474 | branches: [main] |
| 475 | workflow_dispatch: |
| 476 | |
| 477 | permissions: |
| 478 | contents: read |
| 479 | pages: write |
| 480 | id-token: write |
| 481 | |
| 482 | concurrency: |
| 483 | group: pages |
| 484 | cancel-in-progress: true |
| 485 | |
| 486 | jobs: |
| 487 | build: |
| 488 | runs-on: ubuntu-latest |
| 489 | steps: |
| 490 | - uses: actions/checkout@v4 |
| 491 | - uses: actions/setup-python@v5 |
| 492 | with: |
| 493 | python-version: "3.12" |
| 494 | - run: pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__" |
| 495 | - run: pyrtan id wellknown names --domain __DOMAIN__ --out site && echo __DOMAIN__ > site/CNAME |
| 496 | # v3, not v4: v4 leaves out dot-directories, and the names live in .well-known/. |
| 497 | - uses: actions/upload-pages-artifact@v3 |
| 498 | with: |
| 499 | path: site |
| 500 | |
| 501 | deploy: |
| 502 | needs: build |
| 503 | runs-on: ubuntu-latest |
| 504 | environment: |
| 505 | name: github-pages |
| 506 | url: ${{ steps.deployment.outputs.page_url }} |
| 507 | steps: |
| 508 | - id: deployment |
| 509 | uses: actions/deploy-pages@v4 |
| 510 | """ |
| 511 | |
| 512 | _GITLAB = """# Generated by `pyrtan names init`: the names gate for __DOMAIN__, and Pages. |
| 513 | # |
| 514 | # On GitLab a merge request's pipeline runs the merge request's own copy of this |
| 515 | # file, so read any change to it before merging: the gate is pyrtan from a pinned |
| 516 | # source, not from the merge request, but this file says which. Protect the |
| 517 | # default branch and require the pipeline to pass. Pin __REF__ to a commit of |
| 518 | # pyrtan you have read. |
| 519 | |
| 520 | variables: |
| 521 | GIT_DEPTH: "0" |
| 522 | |
| 523 | names-gate: |
| 524 | image: python:3.12 |
| 525 | rules: |
| 526 | - if: $CI_PIPELINE_SOURCE == "merge_request_event" |
| 527 | changes: ["names/**/*"] |
| 528 | script: |
| 529 | - pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__" |
| 530 | - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" |
| 531 | - git worktree add --detach /tmp/base "$(git merge-base FETCH_HEAD HEAD)" |
| 532 | - pyrtan names gate names --base /tmp/base/names --domain __DOMAIN__ |
| 533 | |
| 534 | pages: |
| 535 | image: python:3.12 |
| 536 | rules: |
| 537 | - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH |
| 538 | script: |
| 539 | - pip install "pyrtan[identity] @ git+__PYRTAN__@__REF__" |
| 540 | - pyrtan id wellknown names --domain __DOMAIN__ --out public |
| 541 | artifacts: |
| 542 | paths: [public] |
| 543 | """ |
| 544 | |
| 545 | |
| 546 | def init_repo(directory: Path, domain: str, *, host: str = "github", pyrtan_ref: str = "main") -> tuple[list[Path], list[Path]]: |
| 547 | """Write a names repository for ``domain`` into ``directory``: ``pyrtan-names.json``, a |
| 548 | README, ``names/``, and the gate and Pages for ``host``. Returns ``(written, kept)``: a file |
| 549 | that is already there is kept, never overwritten.""" |
| 550 | domain = identity.check_host(domain.lower()) |
| 551 | if host not in HOSTS: |
| 552 | raise IdentityError(f"host is one of {', '.join(HOSTS)}") |
| 553 | if not re.fullmatch(r"[A-Za-z0-9._/-]{1,80}", pyrtan_ref): |
| 554 | raise IdentityError(f"{pyrtan_ref!r} is not a git ref") |
| 555 | fill = lambda t: t.replace("__DOMAIN__", domain).replace("__REF__", pyrtan_ref).replace("__PYRTAN__", PYRTAN_GIT) # noqa: E731 |
| 556 | files = {REPO_FILE: json.dumps({"pyrtan-names-repo": 1, "domain": domain}, indent=2) + "\n", "README.md": fill(_README), "names/.gitkeep": ""} |
| 557 | if host == "github": |
| 558 | files[".github/workflows/names-gate.yml"] = fill(_GITHUB_GATE) |
| 559 | files[".github/workflows/names-pages.yml"] = fill(_GITHUB_PAGES) |
| 560 | elif host == "gitlab": |
| 561 | files[".gitlab-ci.yml"] = fill(_GITLAB) |
| 562 | written, kept = [], [] |
| 563 | for rel, text in files.items(): |
| 564 | path = directory / rel |
| 565 | if path.exists(): |
| 566 | kept.append(path) |
| 567 | continue |
| 568 | path.parent.mkdir(parents=True, exist_ok=True) |
| 569 | path.write_text(text, encoding="utf-8") |
| 570 | written.append(path) |
| 571 | return written, kept |
| 572 | |
| 573 | |
| 574 | def repo_domain(checkout: Path) -> str | None: |
| 575 | """The domain a names repository (``pyrtan-names.json``) is for, if ``checkout`` is one.""" |
| 576 | try: |
| 577 | data = json.loads((checkout / REPO_FILE).read_text(encoding="utf-8")) |
| 578 | except (OSError, json.JSONDecodeError): |
| 579 | return None |
| 580 | return data.get("domain") if isinstance(data, dict) and data.get("pyrtan-names-repo") == 1 and isinstance(data.get("domain"), str) else None |
| 581 |