diff --git a/.github/workflows/adopt-energyplus.yml b/.github/workflows/adopt-energyplus.yml new file mode 100644 index 000000000..d28e44b49 --- /dev/null +++ b/.github/workflows/adopt-energyplus.yml @@ -0,0 +1,49 @@ +name: adopt-energyplus + +# Adopt one EnergyPlus release in this project, as a draft pull request on `energyplus/X.Y.Z`. +# +# This file holds no logic. The steps, their order, the command each one runs and the gate are +# declared for the project `idfkit-docs` in `governance/energyplus.toml` of idfkit-conformance, and +# the reusable workflow called below executes exactly those commands and no others. It never +# merges, publishes or converts: the caller grants `contents: write` and `pull-requests: write` only. +# +# THE TAG IS STATED TWICE, on `uses:` and as `governance_level`, because a called workflow cannot +# see its own ref. The reusable workflow refuses the run unless the two are the same string, so +# both are changed together or not at all. +# +# THE ORDER IS A RULE. The idfkit level is moved by bump-idfkit.yml and not here. The step that adds +# the release to TARGET_VERSIONS (`python -m scripts.adopt_release target-list`) exits with status +# 1 while the installed idfkit does not list the release, because idfkit returns the newest schema +# it holds for a release it does not list and the conversion would publish the wrong field +# metadata without failing. The report of documentation directories that no configured set matches +# (`python -m scripts.adopt_release doc-sets`) is copied into the pull request body, and into the +# job summary, for a person to accept. +# Converting and publishing the release remains convert-docs.yml, dispatched by a maintainer after +# the merge. +# +# It is dispatched by the watch in idfkit-conformance, and may be dispatched by hand. A second run +# for a release that is already carried changes nothing. +run-name: Adopt EnergyPlus ${{ inputs.release }} + +on: + workflow_dispatch: + inputs: + release: + description: The EnergyPlus release to adopt, as X.Y.Z. + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + adopt: + uses: idfkit/idfkit-conformance/.github/workflows/adopt-energyplus.yml@governance-2026.25 + with: + project: idfkit-docs + governance_level: governance-2026.25 + release: ${{ inputs.release }} + secrets: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/scripts/adopt_release.py b/scripts/adopt_release.py new file mode 100644 index 000000000..5c6f79371 --- /dev/null +++ b/scripts/adopt_release.py @@ -0,0 +1,311 @@ +"""Commands that the adoption of an EnergyPlus release runs in this repository. + +The adoption procedure is held in idfkit-conformance (``energyplus/README.md``). Its version roster +declares two commands for this project, and both are defined here. + +``target-list TAG`` + Adds the release to ``TARGET_VERSIONS`` in ``scripts/config.py`` and designates the newest + listed release ``LATEST_VERSION``. The command refuses, with exit status 1, when the installed + idfkit does not list the release. idfkit returns the newest schema it holds for a release it + does not list, so a conversion against an older level would publish the field metadata of the + previous release under the heading of the new one without failing. The command also refuses + when another patch release of the same ``MAJOR.MINOR`` is listed, because the build and the + published path of a release are named ``vMAJOR.MINOR`` and the two would overwrite each other. + +``doc-sets TAG`` + Reports how the documentation directories of the upstream release map onto ``DOC_SET_INFO``: + LaTeX directories that no configured set matches (converted under a generated title), Sphinx + directories that no configured set matches (omitted from the site), configured sets found in + neither tree, and sets whose source format differs from the preceding release. The command + changes no file and exits with status 0 whatever it finds. A person accepts the report. + +Both commands are idempotent. +""" + +# Each refusal states its own reason, so the messages are composed where they are raised. +# ruff: noqa: TRY003 + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from idfkit.versions import ENERGYPLUS_VERSIONS + +from scripts.config import DOC_SET_INFO, ENERGYPLUS_REPO, EXCLUDED_DIRS, TARGET_VERSIONS, version_to_short +from scripts.rst_doc_sets import SPHINX_EXCLUDED_DIRS, SPHINX_SUBDIR, discover_rst_doc_sets + +CONFIG_PATH = Path(__file__).parent / "config.py" + +_TAG = re.compile(r"v(\d+)\.(\d+)\.(\d+)") +_TARGET_BLOCK = re.compile( + r"(?P^TARGET_VERSIONS: list\[str\] = \[\n)(?P(?: \"v\d+\.\d+\.\d+\",\n)+)(?P\]\n)", + re.MULTILINE, +) +_LATEST_LINE = re.compile(r'^LATEST_VERSION: str = "v\d+\.\d+\.\d+"$', re.MULTILINE) + +# Upper bound for one git command of the upstream fetch. A stalled connection otherwise holds the +# adoption job until its own limit. +_GIT_TIMEOUT_SECONDS = 600 + + +class AdoptionError(Exception): + """The command cannot be performed. The message states the reason.""" + + +@dataclass(frozen=True, order=True) +class ReleaseTag: + """An EnergyPlus release, written ``vMAJOR.MINOR.PATCH`` in this repository.""" + + major: int + minor: int + patch: int + + @classmethod + def parse(cls, text: str) -> ReleaseTag: + match = _TAG.fullmatch(text) + if match is None: + raise AdoptionError(f"'{text}' is not a release tag of the form vMAJOR.MINOR.PATCH") + return cls(int(match.group(1)), int(match.group(2)), int(match.group(3))) + + def as_tuple(self) -> tuple[int, int, int]: + return (self.major, self.minor, self.patch) + + def __str__(self) -> str: + return f"v{self.major}.{self.minor}.{self.patch}" + + +# ── The target list ─────────────────────────────────────────────────────────── + + +def require_library_lists(release: ReleaseTag) -> None: + """Raise unless the installed idfkit lists *release* among its supported versions.""" + if release.as_tuple() not in ENERGYPLUS_VERSIONS: + newest = ReleaseTag(*ENERGYPLUS_VERSIONS[-1]) + raise AdoptionError( + f"the installed idfkit does not list {release}; its newest release is {newest}. " + "Move the idfkit pin in pyproject.toml to a version that carries the release " + "(bump-idfkit.yml) before adding the release to the target list." + ) + + +def with_release(config_text: str, release: ReleaseTag) -> str: + """Return *config_text* with *release* in ``TARGET_VERSIONS`` and the newest entry as ``LATEST_VERSION``.""" + block = _TARGET_BLOCK.search(config_text) + if block is None: + raise AdoptionError("TARGET_VERSIONS was not found in scripts/config.py in the expected layout") + if len(_LATEST_LINE.findall(config_text)) != 1: + raise AdoptionError("LATEST_VERSION was not found exactly once in scripts/config.py") + listed = {ReleaseTag.parse(tag) for tag in re.findall(r'"(v\d+\.\d+\.\d+)"', block.group("body"))} + # Every build and published path is named by version_to_short, that is vMAJOR.MINOR. Two patch + # releases of one MAJOR.MINOR would be built into, and deployed to, the same directory. + same_minor = sorted(tag for tag in listed if (tag.major, tag.minor) == (release.major, release.minor)) + if same_minor and release not in listed: + raise AdoptionError( + f"{same_minor[-1]} is listed and shares the path {version_to_short(str(release))} with {release}. " + f"Remove {same_minor[-1]} from TARGET_VERSIONS in scripts/config.py if {release} replaces it, " + "then run the command again." + ) + ordered = sorted(listed | {release}) + body = "".join(f' "{tag}",\n' for tag in ordered) + text = config_text[: block.start("body")] + body + config_text[block.end("body") :] + return _LATEST_LINE.sub(f'LATEST_VERSION: str = "{ordered[-1]}"', text) + + +def add_to_target_list(release: ReleaseTag, config_path: Path = CONFIG_PATH) -> bool: + """Write *release* into the configuration. Returns whether the file changed.""" + require_library_lists(release) + before = config_path.read_text(encoding="utf-8") + after = with_release(before, release) + if after == before: + return False + config_path.write_text(after, encoding="utf-8") + return True + + +# ── The documentation sets ──────────────────────────────────────────────────── + + +#: The source format a configured set is found in. +SourceFormat = Literal["latex", "rst"] + + +@dataclass(frozen=True) +class DocSetSurvey: + """How the documentation directories of one upstream release map onto ``DOC_SET_INFO``.""" + + release: ReleaseTag + #: Configured set name to the source format it is found in, ``latex`` or ``rst``. + formats: dict[str, SourceFormat] = field(default_factory=dict) + #: LaTeX directories with a main document that no configured set names. + unmatched_latex: tuple[str, ...] = () + #: Sphinx directories with a main document that no configured set or alias matches. + unmatched_sphinx: tuple[str, ...] = () + + @property + def missing(self) -> tuple[str, ...]: + return tuple(name for name in DOC_SET_INFO if name not in self.formats) + + +def _directories(parent: Path, excluded: frozenset[str] | set[str], suffix: str) -> list[Path]: + if not parent.is_dir(): + return [] + return [ + entry + for entry in sorted(parent.iterdir()) + if entry.is_dir() + and entry.name not in excluded + and not entry.name.startswith(".") + and (entry / f"{entry.name}{suffix}").exists() + ] + + +def survey(source_dir: Path, release: ReleaseTag) -> DocSetSurvey: + """Survey a source tree with the rules ``discover_doc_sets`` applies in ``scripts/convert.py``. + + The LaTeX tree takes precedence. A Sphinx directory is used only for a configured set that the + LaTeX tree does not provide. + """ + doc_dir = source_dir / "doc" + if not doc_dir.is_dir(): + raise AdoptionError(f"no doc/ directory at {source_dir}") + latex = [entry.name for entry in _directories(doc_dir, EXCLUDED_DIRS, ".tex")] + formats: dict[str, SourceFormat] = {name: "latex" for name in latex if name in DOC_SET_INFO} + matched_sphinx: set[str] = set() + for doc_set in discover_rst_doc_sets(source_dir, DOC_SET_INFO): + matched_sphinx.add(doc_set.source_dir.name) + formats.setdefault(doc_set.dir_name, "rst") + sphinx = [entry.name for entry in _directories(doc_dir / SPHINX_SUBDIR, SPHINX_EXCLUDED_DIRS, ".rst")] + return DocSetSurvey( + release=release, + formats=formats, + unmatched_latex=tuple(name for name in latex if name not in DOC_SET_INFO), + unmatched_sphinx=tuple(name for name in sphinx if name not in matched_sphinx), + ) + + +@dataclass(frozen=True) +class _GitCommand: + """One git command of the upstream fetch, with the name a failure is reported under.""" + + name: str + arguments: tuple[str, ...] + + +def fetch_entry_documents(release: ReleaseTag, destination: Path) -> Path: + """Check out the entry document of every documentation directory of *release*, and nothing else.""" + target = destination / str(release) + commands = ( + _GitCommand("git clone", ("git", "clone", "--quiet", "--filter=blob:none", "--no-checkout", "--depth=1", "--branch", str(release), "--single-branch", ENERGYPLUS_REPO, str(target))), + _GitCommand("git sparse-checkout", ("git", "-C", str(target), "sparse-checkout", "set", "--no-cone", "/doc/*/*.tex", f"/doc/{SPHINX_SUBDIR}/*/*.rst")), + _GitCommand("git checkout", ("git", "-C", str(target), "checkout", "--quiet")), + ) # fmt: skip + for command in commands: + try: + done = subprocess.run( + command.arguments, capture_output=True, text=True, check=False, timeout=_GIT_TIMEOUT_SECONDS + ) + except subprocess.TimeoutExpired as expired: + raise AdoptionError( + f"{command.name} did not finish within {_GIT_TIMEOUT_SECONDS} s for {release}" + ) from expired + if done.returncode != 0: + raise AdoptionError(f"{command.name} failed for {release}: {done.stderr.strip()}") + return target + + +def preceding(release: ReleaseTag) -> ReleaseTag | None: + """The newest configured release older than *release*, or ``None``.""" + older = [tag for tag in map(ReleaseTag.parse, TARGET_VERSIONS) if tag < release] + return max(older) if older else None + + +def render(current: DocSetSurvey, previous: DocSetSurvey | None) -> str: + """The report a person accepts, as Markdown.""" + + def listing(names: tuple[str, ...] | list[str]) -> list[str]: + return [f"- `{name}`" for name in names] or ["- none"] + + lines = [f"### Documentation sets of EnergyPlus {current.release}", ""] + lines += [f"Configured sets found: {len(current.formats)} of {len(DOC_SET_INFO)}.", ""] + lines += ["**Sphinx directories that no configured set matches (omitted from the site):**", ""] + lines += [*listing(current.unmatched_sphinx), ""] + lines += ["**LaTeX directories that no configured set matches (converted under a generated title):**", ""] + lines += [*listing(current.unmatched_latex), ""] + lines += ["**Configured sets found in neither tree (absent from the build):**", ""] + lines += [*listing(current.missing), ""] + if previous is not None: + moved = [ + f"{name}: {previous.formats[name]} in {previous.release}, {fmt} in {current.release}" + for name, fmt in current.formats.items() + if name in previous.formats and previous.formats[name] != fmt + ] + # The two trees are compared separately. An unmatched directory that moves from the LaTeX + # tree to the Sphinx tree under the same name changes from converted to omitted. + new = [ + *(f"{name} (LaTeX)" for name in current.unmatched_latex if name not in previous.unmatched_latex), + *(f"{name} (Sphinx)" for name in current.unmatched_sphinx if name not in previous.unmatched_sphinx), + ] + lines += [f"**Sets whose source format differs from {previous.release}:**", "", *listing(moved), ""] + lines += [f"**Unmatched directories that {previous.release} did not have:**", "", *listing(new), ""] + else: + lines += ["No preceding release was surveyed, so no comparison with one is reported.", ""] + lines += [ + "Accept when each unmatched directory is either configured in `DOC_SET_INFO` or `DOC_SET_ALIASES` " + "with a title, or stated as excluded, and each set that changed format has been converted once " + f"(`make convert VERSION={current.release}`) and read.", + ] + return "\n".join(lines) + "\n" + + +def report_doc_sets(release: ReleaseTag, source: Path | None) -> str: + with tempfile.TemporaryDirectory() as scratch: + current = survey(source or fetch_entry_documents(release, Path(scratch)), release) + before = preceding(release) + previous = survey(fetch_entry_documents(before, Path(scratch)), before) if before and source is None else None + return render(current, previous) + + +# ── The command line ────────────────────────────────────────────────────────── + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Commands of an EnergyPlus release adoption") + commands = parser.add_subparsers(dest="command", required=True) + target = commands.add_parser( + "target-list", help="Add a release to TARGET_VERSIONS and designate the newest as latest" + ) + target.add_argument("release", help="Release tag (e.g., v25.2.0)") + sets = commands.add_parser("doc-sets", help="Report how upstream documentation directories map onto DOC_SET_INFO") + sets.add_argument("release", help="Release tag (e.g., v25.2.0)") + sets.add_argument("--source", type=Path, help="An EnergyPlus source tree to survey instead of fetching the release") + args = parser.parse_args(argv) + + try: + release = ReleaseTag.parse(args.release) + if args.command == "target-list": + changed = add_to_target_list(release) + print(f"{release}: {'added to' if changed else 'already in'} scripts/config.py") + return 0 + report = report_doc_sets(release, args.source) + except AdoptionError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + print(report) + # The adoption run copies the output of this step into the pull request body. The job summary + # holds the same report rendered as Markdown. + if summary := os.environ.get("GITHUB_STEP_SUMMARY"): + with Path(summary).open("a", encoding="utf-8") as handle: + handle.write(report) + return 0 + + +if __name__ == "__main__": + sys.exit(main())