Seed/update .ci pipeline from manifest-updater v1.1.0
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a published update manifest from a plugin repo's base manifest + sections.
|
||||
|
||||
Foundation model: a plugin repo curates a base `manifest.json` (rich, static
|
||||
fields) + section content; the release pipeline computes the moving parts
|
||||
(version, download_url, requires/tested from readme, icons, last_updated) and
|
||||
hands them in as `--computed`. This merges the two and writes the channel manifest.
|
||||
|
||||
SECTIONS (the "View details" modal body) resolve PER SECTION, gap-filling down a
|
||||
cascade — Manifest-defined > ./sections/<name>.md > auto-generate:
|
||||
1. base `manifest.json` `sections` — used verbatim, EXCEPT a section whose body
|
||||
is a SINGLE line `@path.(md|html|txt)` existing under the repo root is
|
||||
replaced by that file's converted contents (.md->HTML, .html->as-is,
|
||||
.txt->escaped text).
|
||||
2. `<repo-root>/sections/<name>.md` fills any section not set by (1).
|
||||
3. auto-generate (toggle via mu-release.yaml `sections.autogen`, default true):
|
||||
description <- README.md (fallback: readme.txt `== Description ==`)
|
||||
changelog <- CHANGELOG.md
|
||||
...for description/changelog only, if still unset.
|
||||
|
||||
`sections.strip_title` (mu-release.yaml) removes a leading H1 title from PARSED
|
||||
markdown only (never inline HTML / .html / .txt):
|
||||
null | false -> off (default)
|
||||
true -> strip from every parsed .md section
|
||||
{ "<name>": true|false } -> per-section
|
||||
|
||||
Computed fields always win for release-critical/readme-derived keys; the base
|
||||
manifest wins for everything else it provides.
|
||||
|
||||
Usage:
|
||||
build-manifest.py --repo-root . --computed computed.json --out out.json [--config mu-release.yaml]
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import markdown as _md
|
||||
except ImportError:
|
||||
sys.exit("build-manifest: python 'markdown' is required (pip install markdown)")
|
||||
|
||||
# Always taken from --computed; never overridable by the curated base manifest.
|
||||
PROTECTED = {
|
||||
"version", "slug", "download_url", "last_updated",
|
||||
"icons", "requires", "tested", "requires_php",
|
||||
}
|
||||
SECTIONS_DIR = "sections"
|
||||
INCLUDE_RE = re.compile(r"^@(?P<path>.+\.(?P<ext>md|html|txt))$")
|
||||
|
||||
|
||||
def md_to_html(text: str) -> str:
|
||||
return _md.markdown(text, extensions=["extra", "sane_lists", "nl2br"])
|
||||
|
||||
|
||||
def strip_leading_title(md: str) -> str:
|
||||
"""Drop a leading H1 (atx `# Title` or setext `Title\\n===`) from markdown."""
|
||||
lines = md.splitlines()
|
||||
i = 0
|
||||
while i < len(lines) and lines[i].strip() == "":
|
||||
i += 1
|
||||
if i < len(lines):
|
||||
if re.match(r"^#\s+\S", lines[i]): # atx H1
|
||||
i += 1
|
||||
elif i + 1 < len(lines) and lines[i].strip() and re.match(r"^=+\s*$", lines[i + 1]):
|
||||
i += 2 # setext H1
|
||||
return "\n".join(lines[i:]).lstrip("\n")
|
||||
|
||||
|
||||
def should_strip(name: str, strip_cfg) -> bool:
|
||||
if strip_cfg is True:
|
||||
return True
|
||||
if isinstance(strip_cfg, dict):
|
||||
return bool(strip_cfg.get(name, False))
|
||||
return False # None / False / anything else
|
||||
|
||||
|
||||
def md_section(text: str, name: str, strip_cfg) -> str:
|
||||
if should_strip(name, strip_cfg):
|
||||
text = strip_leading_title(text)
|
||||
return md_to_html(text)
|
||||
|
||||
|
||||
def convert(text: str, ext: str, name: str, strip_cfg) -> str:
|
||||
if ext == "md":
|
||||
return md_section(text, name, strip_cfg)
|
||||
if ext == "html":
|
||||
return text # already HTML — not parsed, never title-stripped.
|
||||
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||
return "".join("<p>" + html.escape(p).replace("\n", "<br>") + "</p>" for p in paras)
|
||||
|
||||
|
||||
def resolve_in_repo(repo_root: str, rel: str):
|
||||
root = os.path.realpath(repo_root)
|
||||
full = os.path.realpath(os.path.join(root, rel))
|
||||
if full != root and not full.startswith(root + os.sep):
|
||||
return None # path traversal — reject.
|
||||
return full if os.path.isfile(full) else None
|
||||
|
||||
|
||||
def read_text(repo_root: str, name: str):
|
||||
p = os.path.join(repo_root, name)
|
||||
return open(p, encoding="utf-8").read() if os.path.isfile(p) else None
|
||||
|
||||
|
||||
def readme_txt_description(repo_root: str):
|
||||
"""Fallback description source: the `== Description ==` block of readme.txt."""
|
||||
text = read_text(repo_root, "readme.txt")
|
||||
if not text:
|
||||
return None
|
||||
m = re.search(r"(?ms)^==\s*Description\s*==\s*\n(.*?)(?=^==\s|\Z)", text)
|
||||
return (m.group(1).strip() or None) if m else None
|
||||
|
||||
|
||||
def build_sections(base: dict, repo_root: str, autogen: bool, strip_cfg) -> dict:
|
||||
resolved = {}
|
||||
|
||||
# 1. manifest-defined (with @include resolution).
|
||||
if isinstance(base.get("sections"), dict):
|
||||
for name, body in base["sections"].items():
|
||||
name = str(name)
|
||||
if isinstance(body, str):
|
||||
one = body.strip()
|
||||
m = INCLUDE_RE.match(one) if "\n" not in one else None
|
||||
if m:
|
||||
full = resolve_in_repo(repo_root, m.group("path"))
|
||||
if full:
|
||||
with open(full, encoding="utf-8") as fh:
|
||||
resolved[name] = convert(fh.read(), m.group("ext"), name, strip_cfg)
|
||||
continue
|
||||
sys.stderr.write(
|
||||
f"build-manifest: section '{name}' include "
|
||||
f"'{m.group('path')}' not found — using literal\n"
|
||||
)
|
||||
resolved[name] = body
|
||||
else:
|
||||
resolved[name] = str(body)
|
||||
|
||||
# 2. ./sections/<name>.md fills any section not already set.
|
||||
sdir = os.path.join(repo_root, SECTIONS_DIR)
|
||||
if os.path.isdir(sdir):
|
||||
for fn in sorted(os.listdir(sdir)):
|
||||
if fn.endswith(".md") and fn[:-3] not in resolved:
|
||||
with open(os.path.join(sdir, fn), encoding="utf-8") as fh:
|
||||
resolved[fn[:-3]] = md_section(fh.read(), fn[:-3], strip_cfg)
|
||||
|
||||
# 3. auto-generate description (README.md, then readme.txt) + changelog (CHANGELOG.md).
|
||||
if autogen:
|
||||
if "description" not in resolved:
|
||||
raw = read_text(repo_root, "README.md")
|
||||
if not (raw and raw.strip()):
|
||||
raw = readme_txt_description(repo_root)
|
||||
if raw and raw.strip():
|
||||
resolved["description"] = md_section(raw, "description", strip_cfg)
|
||||
if "changelog" not in resolved:
|
||||
cl = read_text(repo_root, "CHANGELOG.md")
|
||||
if cl and cl.strip():
|
||||
resolved["changelog"] = md_section(cl, "changelog", strip_cfg)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def read_config(path: str) -> dict:
|
||||
if path and os.path.isfile(path):
|
||||
import yaml
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh) or {}
|
||||
return {}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repo-root", required=True)
|
||||
ap.add_argument("--computed", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--config", default="", help="mu-release.yaml (for toggles)")
|
||||
args = ap.parse_args()
|
||||
|
||||
repo_root = args.repo_root
|
||||
cfg = read_config(args.config)
|
||||
sec_cfg = cfg.get("sections") or {}
|
||||
autogen = bool(sec_cfg.get("autogen", True))
|
||||
strip_cfg = sec_cfg.get("strip_title") # None | bool | dict
|
||||
|
||||
base = {}
|
||||
base_path = os.path.join(repo_root, "manifest.json")
|
||||
if os.path.isfile(base_path):
|
||||
with open(base_path, encoding="utf-8") as fh:
|
||||
base = json.load(fh)
|
||||
if not isinstance(base, dict):
|
||||
sys.exit("build-manifest: base manifest.json must be a JSON object")
|
||||
|
||||
with open(args.computed, encoding="utf-8") as fh:
|
||||
computed = json.load(fh)
|
||||
|
||||
sections = build_sections(base, repo_root, autogen, strip_cfg)
|
||||
|
||||
result = dict(computed)
|
||||
for k, v in base.items():
|
||||
if k == "sections" or k in PROTECTED:
|
||||
continue
|
||||
result[k] = v
|
||||
if sections:
|
||||
result["sections"] = sections
|
||||
|
||||
with open(args.out, "w", encoding="utf-8") as fh:
|
||||
json.dump(result, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
sys.stderr.write(
|
||||
f"build-manifest: wrote {args.out} "
|
||||
f"(autogen={autogen}, strip_title={strip_cfg!r}; sections: {', '.join(sections) or 'none'})\n"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user