Seed/update .ci pipeline from manifest-updater v1.1.1
This commit is contained in:
+101
-96
@@ -1,34 +1,31 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Build a published update manifest from a plugin repo's base manifest + sections.
|
"""Build a published update manifest from a plugin repo's curated inputs.
|
||||||
|
|
||||||
Foundation model: a plugin repo curates a base `manifest.json` (rich, static
|
Inputs (all WP-native / YAML, all export-ignored from the shipped zip):
|
||||||
fields) + section content; the release pipeline computes the moving parts
|
.mu-manifest.yaml curated base — name/homepage/author/tags + optional section
|
||||||
(version, download_url, requires/tested from readme, icons, last_updated) and
|
OVERRIDES (inline HTML, or `@path.(md|html|txt)` includes).
|
||||||
hands them in as `--computed`. This merges the two and writes the channel manifest.
|
.mu-release.yaml pipeline config (passed via --config): sections.autogen,
|
||||||
|
sections.strip_title, channel, app_build.
|
||||||
|
readme.txt the WordPress readme — its `== Section ==` blocks are the
|
||||||
|
DEFAULT modal sections (Description / Installation / FAQ /
|
||||||
|
Changelog / …). The `=== Title ===` header + the tag block
|
||||||
|
are naturally skipped (they aren't `== … ==`).
|
||||||
|
CHANGELOG.md changelog fallback, used only if readme.txt has no Changelog.
|
||||||
|
|
||||||
SECTIONS (the "View details" modal body) resolve PER SECTION, gap-filling down a
|
SECTIONS cascade, per modal key: .mu-manifest.yaml override > readme.txt block
|
||||||
cascade — Manifest-defined > ./sections/<name>.md > auto-generate:
|
( + changelog: ... > readme.txt == Changelog == > CHANGELOG.md ).
|
||||||
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
|
readme.txt is a markdown subset; its `= Subhead =` lines render as bold paragraphs
|
||||||
markdown only (never inline HTML / .html / .txt):
|
(not <h4>) because WP's modal puts `clear:both` on headings, which — when a section
|
||||||
null | false -> off (default)
|
LEADS with one — drops the body below the floated sidebar (a big top gap). For the
|
||||||
true -> strip from every parsed .md section
|
same reason a leading <h1–6> is demoted to <p><strong>…</strong></p>. `strip_title`
|
||||||
{ "<name>": true|false } -> per-section
|
(null|bool|dict) removes a leading H1 from parsed markdown before that.
|
||||||
|
|
||||||
Computed fields always win for release-critical/readme-derived keys; the base
|
Computed fields (--computed) always win for release-critical/readme-derived keys;
|
||||||
manifest wins for everything else it provides.
|
the curated base wins for everything else it provides.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
build-manifest.py --repo-root . --computed computed.json --out out.json [--config mu-release.yaml]
|
build-manifest.py --repo-root . --computed computed.json --out out.json [--config .mu-release.yaml]
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import html
|
import html
|
||||||
@@ -39,55 +36,84 @@ import sys
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import markdown as _md
|
import markdown as _md
|
||||||
except ImportError:
|
import yaml
|
||||||
sys.exit("build-manifest: python 'markdown' is required (pip install markdown)")
|
except ImportError as e:
|
||||||
|
sys.exit(f"build-manifest: needs python 'markdown' + 'pyyaml' ({e})")
|
||||||
|
|
||||||
# Always taken from --computed; never overridable by the curated base manifest.
|
|
||||||
PROTECTED = {
|
PROTECTED = {
|
||||||
"version", "slug", "download_url", "last_updated",
|
"version", "slug", "download_url", "last_updated",
|
||||||
"icons", "requires", "tested", "requires_php",
|
"icons", "requires", "tested", "requires_php",
|
||||||
}
|
}
|
||||||
SECTIONS_DIR = "sections"
|
|
||||||
INCLUDE_RE = re.compile(r"^@(?P<path>.+\.(?P<ext>md|html|txt))$")
|
INCLUDE_RE = re.compile(r"^@(?P<path>.+\.(?P<ext>md|html|txt))$")
|
||||||
|
BASE_FILE = ".mu-manifest.yaml"
|
||||||
|
|
||||||
|
# readme.txt section name -> WP modal section key.
|
||||||
|
_KEYS = {
|
||||||
|
"description": "description", "installation": "installation",
|
||||||
|
"faq": "faq", "frequently asked questions": "faq",
|
||||||
|
"changelog": "changelog", "change log": "changelog",
|
||||||
|
"screenshots": "screenshots", "other notes": "other_notes",
|
||||||
|
"upgrade notice": "upgrade_notice",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def section_key(name: str) -> str:
|
||||||
|
n = " ".join(name.strip().lower().split())
|
||||||
|
return _KEYS.get(n) or re.sub(r"[^a-z0-9]+", "_", n).strip("_")
|
||||||
|
|
||||||
|
|
||||||
def md_to_html(text: str) -> str:
|
def md_to_html(text: str) -> str:
|
||||||
return _md.markdown(text, extensions=["extra", "sane_lists", "nl2br"])
|
return _md.markdown(text, extensions=["extra", "sane_lists", "nl2br"])
|
||||||
|
|
||||||
|
|
||||||
|
def wporg_subheads(text: str) -> str:
|
||||||
|
"""wp.org `= Subhead =` -> bold paragraph (NOT a heading: WP's modal clears
|
||||||
|
floats on h*, so heading-led content gaps below the sidebar)."""
|
||||||
|
return re.sub(r"(?m)^=[ \t]+(.+?)[ \t]*=[ \t]*$", r"**\1**", text)
|
||||||
|
|
||||||
|
|
||||||
def strip_leading_title(md: str) -> str:
|
def strip_leading_title(md: str) -> str:
|
||||||
"""Drop a leading H1 (atx `# Title` or setext `Title\\n===`) from markdown."""
|
|
||||||
lines = md.splitlines()
|
lines = md.splitlines()
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(lines) and lines[i].strip() == "":
|
while i < len(lines) and lines[i].strip() == "":
|
||||||
i += 1
|
i += 1
|
||||||
if i < len(lines):
|
if i < len(lines):
|
||||||
if re.match(r"^#\s+\S", lines[i]): # atx H1
|
if re.match(r"^#\s+\S", lines[i]):
|
||||||
i += 1
|
i += 1
|
||||||
elif i + 1 < len(lines) and lines[i].strip() and re.match(r"^=+\s*$", lines[i + 1]):
|
elif i + 1 < len(lines) and lines[i].strip() and re.match(r"^=+\s*$", lines[i + 1]):
|
||||||
i += 2 # setext H1
|
i += 2
|
||||||
return "\n".join(lines[i:]).lstrip("\n")
|
return "\n".join(lines[i:]).lstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
def should_strip(name: str, strip_cfg) -> bool:
|
def demote_leading_heading(html_str: str) -> str:
|
||||||
|
"""Demote a LEADING <h1–6> to a non-clearing <p><strong>…</strong></p> so the
|
||||||
|
section doesn't trip the modal's `clear:both` on headings (survives wp_kses)."""
|
||||||
|
return re.sub(
|
||||||
|
r"^\s*<h[1-6][^>]*>(.*?)</h[1-6]>",
|
||||||
|
r"<p><strong>\1</strong></p>",
|
||||||
|
html_str, count=1, flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def should_strip(key: str, strip_cfg) -> bool:
|
||||||
if strip_cfg is True:
|
if strip_cfg is True:
|
||||||
return True
|
return True
|
||||||
if isinstance(strip_cfg, dict):
|
if isinstance(strip_cfg, dict):
|
||||||
return bool(strip_cfg.get(name, False))
|
return bool(strip_cfg.get(key, False))
|
||||||
return False # None / False / anything else
|
return False
|
||||||
|
|
||||||
|
|
||||||
def md_section(text: str, name: str, strip_cfg) -> str:
|
def md_section(text: str, key: str, strip_cfg) -> str:
|
||||||
if should_strip(name, strip_cfg):
|
if should_strip(key, strip_cfg):
|
||||||
text = strip_leading_title(text)
|
text = strip_leading_title(text)
|
||||||
return md_to_html(text)
|
return md_to_html(text)
|
||||||
|
|
||||||
|
|
||||||
def convert(text: str, ext: str, name: str, strip_cfg) -> str:
|
def convert(text: str, ext: str, key: str, strip_cfg) -> str:
|
||||||
if ext == "md":
|
if ext == "md":
|
||||||
return md_section(text, name, strip_cfg)
|
return md_section(text, key, strip_cfg)
|
||||||
if ext == "html":
|
if ext == "html":
|
||||||
return text # already HTML — not parsed, never title-stripped.
|
return text
|
||||||
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
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)
|
return "".join("<p>" + html.escape(p).replace("\n", "<br>") + "</p>" for p in paras)
|
||||||
|
|
||||||
@@ -96,7 +122,7 @@ def resolve_in_repo(repo_root: str, rel: str):
|
|||||||
root = os.path.realpath(repo_root)
|
root = os.path.realpath(repo_root)
|
||||||
full = os.path.realpath(os.path.join(root, rel))
|
full = os.path.realpath(os.path.join(root, rel))
|
||||||
if full != root and not full.startswith(root + os.sep):
|
if full != root and not full.startswith(root + os.sep):
|
||||||
return None # path traversal — reject.
|
return None
|
||||||
return full if os.path.isfile(full) else None
|
return full if os.path.isfile(full) else None
|
||||||
|
|
||||||
|
|
||||||
@@ -105,69 +131,52 @@ def read_text(repo_root: str, name: str):
|
|||||||
return open(p, encoding="utf-8").read() if os.path.isfile(p) else None
|
return open(p, encoding="utf-8").read() if os.path.isfile(p) else None
|
||||||
|
|
||||||
|
|
||||||
def readme_txt_description(repo_root: str):
|
def parse_readme_sections(repo_root: str) -> dict:
|
||||||
"""Fallback description source: the `== Description ==` block of readme.txt."""
|
"""Split readme.txt on `== Name ==` lines (two equals; the `=== Title ===`
|
||||||
|
header and the tag block above the first section are skipped)."""
|
||||||
text = read_text(repo_root, "readme.txt")
|
text = read_text(repo_root, "readme.txt")
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return {}
|
||||||
m = re.search(r"(?ms)^==\s*Description\s*==\s*\n(.*?)(?=^==\s|\Z)", text)
|
parts = re.split(r"(?m)^==[ \t]+(.+?)[ \t]+==[ \t]*$", text)
|
||||||
return (m.group(1).strip() or None) if m else None
|
out = {}
|
||||||
|
for i in range(1, len(parts) - 1, 2):
|
||||||
|
name, body = parts[i].strip(), parts[i + 1].strip()
|
||||||
|
if name and body:
|
||||||
|
out[name] = body
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def build_sections(base: dict, repo_root: str, autogen: bool, strip_cfg) -> dict:
|
def build_sections(base: dict, repo_root: str, autogen: bool, strip_cfg) -> dict:
|
||||||
resolved = {}
|
resolved = {}
|
||||||
|
# 1. curated overrides (.mu-manifest.yaml sections).
|
||||||
# 1. manifest-defined (with @include resolution).
|
|
||||||
if isinstance(base.get("sections"), dict):
|
if isinstance(base.get("sections"), dict):
|
||||||
for name, body in base["sections"].items():
|
for name, body in base["sections"].items():
|
||||||
name = str(name)
|
key = section_key(str(name))
|
||||||
if isinstance(body, str):
|
if isinstance(body, str):
|
||||||
one = body.strip()
|
one = body.strip()
|
||||||
m = INCLUDE_RE.match(one) if "\n" not in one else None
|
m = INCLUDE_RE.match(one) if "\n" not in one else None
|
||||||
if m:
|
if m:
|
||||||
full = resolve_in_repo(repo_root, m.group("path"))
|
full = resolve_in_repo(repo_root, m.group("path"))
|
||||||
if full:
|
if full:
|
||||||
with open(full, encoding="utf-8") as fh:
|
resolved[key] = convert(open(full, encoding="utf-8").read(), m.group("ext"), key, strip_cfg)
|
||||||
resolved[name] = convert(fh.read(), m.group("ext"), name, strip_cfg)
|
|
||||||
continue
|
continue
|
||||||
sys.stderr.write(
|
sys.stderr.write(f"build-manifest: section '{key}' include '{m.group('path')}' not found — literal\n")
|
||||||
f"build-manifest: section '{name}' include "
|
resolved[key] = body
|
||||||
f"'{m.group('path')}' not found — using literal\n"
|
|
||||||
)
|
|
||||||
resolved[name] = body
|
|
||||||
else:
|
else:
|
||||||
resolved[name] = str(body)
|
resolved[key] = 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 autogen:
|
||||||
if "description" not in resolved:
|
# 2. readme.txt == Section == blocks (the WP-native default).
|
||||||
raw = read_text(repo_root, "README.md")
|
for name, body in parse_readme_sections(repo_root).items():
|
||||||
if not (raw and raw.strip()):
|
key = section_key(name)
|
||||||
raw = readme_txt_description(repo_root)
|
if key not in resolved:
|
||||||
if raw and raw.strip():
|
resolved[key] = md_section(wporg_subheads(body), key, strip_cfg)
|
||||||
resolved["description"] = md_section(raw, "description", strip_cfg)
|
# 3. changelog fallback -> CHANGELOG.md.
|
||||||
if "changelog" not in resolved:
|
if "changelog" not in resolved:
|
||||||
cl = read_text(repo_root, "CHANGELOG.md")
|
cl = read_text(repo_root, "CHANGELOG.md")
|
||||||
if cl and cl.strip():
|
if cl and cl.strip():
|
||||||
resolved["changelog"] = md_section(cl, "changelog", strip_cfg)
|
resolved["changelog"] = md_section(cl, "changelog", strip_cfg)
|
||||||
|
# No section may LEAD with a heading (modal clear:both gap fix).
|
||||||
return resolved
|
return {k: demote_leading_heading(v) for k, v in resolved.items()}
|
||||||
|
|
||||||
|
|
||||||
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:
|
def main() -> None:
|
||||||
@@ -175,26 +184,25 @@ def main() -> None:
|
|||||||
ap.add_argument("--repo-root", required=True)
|
ap.add_argument("--repo-root", required=True)
|
||||||
ap.add_argument("--computed", required=True)
|
ap.add_argument("--computed", required=True)
|
||||||
ap.add_argument("--out", required=True)
|
ap.add_argument("--out", required=True)
|
||||||
ap.add_argument("--config", default="", help="mu-release.yaml (for toggles)")
|
ap.add_argument("--config", default="")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
repo_root = args.repo_root
|
repo_root = args.repo_root
|
||||||
cfg = read_config(args.config)
|
cfg = {}
|
||||||
|
if args.config and os.path.isfile(args.config):
|
||||||
|
cfg = yaml.safe_load(open(args.config, encoding="utf-8")) or {}
|
||||||
sec_cfg = cfg.get("sections") or {}
|
sec_cfg = cfg.get("sections") or {}
|
||||||
autogen = bool(sec_cfg.get("autogen", True))
|
autogen = bool(sec_cfg.get("autogen", True))
|
||||||
strip_cfg = sec_cfg.get("strip_title") # None | bool | dict
|
strip_cfg = sec_cfg.get("strip_title")
|
||||||
|
|
||||||
base = {}
|
base = {}
|
||||||
base_path = os.path.join(repo_root, "manifest.json")
|
base_path = os.path.join(repo_root, BASE_FILE)
|
||||||
if os.path.isfile(base_path):
|
if os.path.isfile(base_path):
|
||||||
with open(base_path, encoding="utf-8") as fh:
|
base = yaml.safe_load(open(base_path, encoding="utf-8")) or {}
|
||||||
base = json.load(fh)
|
|
||||||
if not isinstance(base, dict):
|
if not isinstance(base, dict):
|
||||||
sys.exit("build-manifest: base manifest.json must be a JSON object")
|
sys.exit(f"build-manifest: {BASE_FILE} must be a mapping")
|
||||||
|
|
||||||
with open(args.computed, encoding="utf-8") as fh:
|
|
||||||
computed = json.load(fh)
|
|
||||||
|
|
||||||
|
computed = json.load(open(args.computed, encoding="utf-8"))
|
||||||
sections = build_sections(base, repo_root, autogen, strip_cfg)
|
sections = build_sections(base, repo_root, autogen, strip_cfg)
|
||||||
|
|
||||||
result = dict(computed)
|
result = dict(computed)
|
||||||
@@ -208,10 +216,7 @@ def main() -> None:
|
|||||||
with open(args.out, "w", encoding="utf-8") as fh:
|
with open(args.out, "w", encoding="utf-8") as fh:
|
||||||
json.dump(result, fh, indent=2, ensure_ascii=False)
|
json.dump(result, fh, indent=2, ensure_ascii=False)
|
||||||
fh.write("\n")
|
fh.write("\n")
|
||||||
sys.stderr.write(
|
sys.stderr.write(f"build-manifest: wrote {args.out} (sections: {', '.join(sections) or 'none'})\n")
|
||||||
f"build-manifest: wrote {args.out} "
|
|
||||||
f"(autogen={autogen}, strip_title={strip_cfg!r}; sections: {', '.join(sections) or 'none'})\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+3
-3
@@ -20,16 +20,16 @@ SLUG="$(basename "${GITHUB_REPOSITORY}")"
|
|||||||
VER="${GITHUB_REF_NAME#v}"
|
VER="${GITHUB_REF_NAME#v}"
|
||||||
MAIN="${SLUG}.php"
|
MAIN="${SLUG}.php"
|
||||||
HOST="${GITHUB_SERVER_URL}"
|
HOST="${GITHUB_SERVER_URL}"
|
||||||
CFG="mu-release.yaml"
|
CFG=".mu-release.yaml"
|
||||||
|
|
||||||
# --- config helpers (yaml via python; pure-convention defaults if absent) -----
|
# --- config helpers (yaml via python; pure-convention defaults if absent) -----
|
||||||
cfg() { python3 - "$1" "$2" <<'PY' 2>/dev/null || true
|
cfg() { python3 - "$1" "$2" <<'PY' 2>/dev/null || true
|
||||||
import sys, os
|
import sys, os
|
||||||
key, default = sys.argv[1], sys.argv[2]
|
key, default = sys.argv[1], sys.argv[2]
|
||||||
val = default
|
val = default
|
||||||
if os.path.isfile("mu-release.yaml"):
|
if os.path.isfile(".mu-release.yaml"):
|
||||||
import yaml
|
import yaml
|
||||||
data = yaml.safe_load(open("mu-release.yaml")) or {}
|
data = yaml.safe_load(open(".mu-release.yaml")) or {}
|
||||||
cur = data
|
cur = data
|
||||||
for part in key.split("."):
|
for part in key.split("."):
|
||||||
cur = cur.get(part) if isinstance(cur, dict) else None
|
cur = cur.get(part) if isinstance(cur, dict) else None
|
||||||
|
|||||||
Reference in New Issue
Block a user