diff --git a/actions/release-publication/action.yml b/actions/release-publication/action.yml index 2d4cfb6..31ac331 100644 --- a/actions/release-publication/action.yml +++ b/actions/release-publication/action.yml @@ -35,6 +35,12 @@ outputs: verification-artifact-name: description: Artifact containing PublicationVerification v1 and its upstream release state. value: ${{ steps.verify.outputs.verification-artifact-name }} + prepared-path: + description: PreparedRelease v1 JSON path for downstream steps in the same job. + value: ${{ runner.temp }}/release-publication-state/prepared-release.json + verification-path: + description: PublicationVerification v1 JSON path for downstream steps in the same job. + value: ${{ runner.temp }}/release-publication-state/publication-verification.json runs: using: composite diff --git a/actions/sync-release-history/action.yml b/actions/sync-release-history/action.yml new file mode 100644 index 0000000..548f82b --- /dev/null +++ b/actions/sync-release-history/action.yml @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Synchronize release history +description: Generate a reviewable documentation PR from a successful PublicationVerification v1. + +inputs: + prepared-path: + description: PreparedRelease v1 JSON path. + required: true + verification-path: + description: Successful PublicationVerification v1 JSON path. + required: true + documentation-repository: + description: Documentation repository in owner/name form. + required: true + app-id: + description: GitHub App id used for the documentation-scoped token. + required: true + app-private-key: + description: GitHub App private key. + required: true + +outputs: + pull-request-number: + description: Documentation synchronization PR number. + value: ${{ steps.pull-request.outputs.pull-request-number }} + pull-request-url: + description: Documentation synchronization PR URL. + value: ${{ steps.pull-request.outputs.pull-request-url }} + +runs: + using: composite + steps: + - id: docs-repository + name: Resolve documentation repository + shell: bash + env: + DOCUMENTATION_REPOSITORY: ${{ inputs.documentation-repository }} + run: | + set -euo pipefail + echo "owner=${DOCUMENTATION_REPOSITORY%%/*}" >> "${GITHUB_OUTPUT}" + echo "name=${DOCUMENTATION_REPOSITORY#*/}" >> "${GITHUB_OUTPUT}" + + - id: app-token + name: Create documentation token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.app-private-key }} + owner: ${{ steps.docs-repository.outputs.owner }} + repositories: ${{ steps.docs-repository.outputs.name }} + permission-contents: write + permission-pull-requests: write + + - name: Checkout documentation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.documentation-repository }} + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + path: release-docs + + - id: render + name: Render release history + shell: bash + env: + PREPARED_PATH: ${{ inputs.prepared-path }} + VERIFICATION_PATH: ${{ inputs.verification-path }} + run: | + set -euo pipefail + python3 "${GITHUB_ACTION_PATH}/../../scripts/sync_release_history.py" \ + --prepared "${PREPARED_PATH}" \ + --verification "${VERIFICATION_PATH}" \ + --docs-root release-docs \ + > "${RUNNER_TEMP}/release-history-sync.json" + + - id: pull-request + name: Create or update documentation pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + path: release-docs + commit-message: 'docs: synchronize LibreSign release history' + committer: GitHub + author: github-workflows bot + signoff: true + branch: automated/libresign-release-history + delete-branch: true + title: 'docs: synchronize LibreSign release history' + body: | + Automated release-history synchronization after successful PublicationVerification v1. + + Release text is generated from the canonical per-major changelog in LibreSign/libresign. Do not edit generated release text manually in this repository. + add-paths: | + developer_manual/release-history/** diff --git a/scripts/sync_release_history.py b/scripts/sync_release_history.py new file mode 100755 index 0000000..88b345c --- /dev/null +++ b/scripts/sync_release_history.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +VERSION_RE = re.compile(r"^(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P
[A-Za-z0-9.-]+))?$")
+HEADING_RE = re.compile(r"^(#{2,3})\s+(.+?)\s*$")
+LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)]+)\)")
+CODE_RE = re.compile(r"`([^`]+)`")
+
+
+def load_json(path: Path) -> dict[str, object]:
+    payload = json.loads(path.read_text(encoding="utf-8"))
+    if not isinstance(payload, dict):
+        raise ValueError(f"{path} must contain a JSON object")
+    return payload
+
+
+def inline_rst(text: str) -> str:
+    text = CODE_RE.sub(lambda match: f"``{match.group(1)}``", text)
+    text = LINK_RE.sub(lambda match: f"`{match.group(1)} <{match.group(2)}>`_", text)
+    return text
+
+
+def markdown_section_to_rst(section: str, version: str) -> str:
+    lines = section.strip().splitlines()
+    if not lines:
+        raise ValueError("changelog section must not be empty")
+
+    output: list[str] = []
+    first_heading_seen = False
+    for raw in lines:
+        match = HEADING_RE.match(raw)
+        if match:
+            level, title = match.groups()
+            title = inline_rst(title)
+            if level == "##":
+                if first_heading_seen:
+                    raise ValueError("release section contains more than one version heading")
+                first_heading_seen = True
+                if not title.startswith(version):
+                    raise ValueError(f"release heading does not start with version {version}")
+                output.extend([title, "=" * len(title), ""])
+            else:
+                output.extend([title, "-" * len(title), ""])
+            continue
+
+        if raw.startswith("- "):
+            output.append("* " + inline_rst(raw[2:]))
+        else:
+            output.append(inline_rst(raw))
+
+    if not first_heading_seen:
+        raise ValueError("release section does not contain a version heading")
+    return "\n".join(output).rstrip() + "\n"
+
+
+def release_filename(version: str) -> str:
+    if VERSION_RE.fullmatch(version) is None:
+        raise ValueError(f"unsupported release version: {version}")
+    return f"{version}.rst"
+
+
+def version_key(version: str) -> tuple[int, int, int, int, str]:
+    match = VERSION_RE.fullmatch(version)
+    if match is None:
+        raise ValueError(f"unsupported release version: {version}")
+    prerelease = match.group("pre")
+    return (
+        int(match.group("major")),
+        int(match.group("minor")),
+        int(match.group("patch")),
+        1 if prerelease is None else 0,
+        prerelease or "",
+    )
+
+
+def render_major_index(major: int, versions: list[str]) -> str:
+    ordered = sorted(versions, key=version_key, reverse=True)
+    title = f"LibreSign {major}"
+    body = [
+        ".. This file is generated from LibreSign release history. Do not edit release text here manually.",
+        "",
+        title,
+        "=" * len(title),
+        "",
+        ".. toctree::",
+        "   :maxdepth: 1",
+        "",
+    ]
+    body.extend(f"   {version}" for version in ordered)
+    return "\n".join(body) + "\n"
+
+
+def render_root_index(majors: list[int]) -> str:
+    title = "Release history"
+    body = [
+        ".. This file is generated. Release text is sourced from LibreSign/libresign per-major changelogs.",
+        "",
+        title,
+        "=" * len(title),
+        "",
+        "Published LibreSign release history is generated after publication verification succeeds.",
+        "",
+        ".. toctree::",
+        "   :maxdepth: 2",
+        "",
+    ]
+    body.extend(f"   LibreSign {major} <{major}/index>" for major in sorted(majors, reverse=True))
+    return "\n".join(body) + "\n"
+
+
+def synchronize(prepared_path: Path, verification_path: Path, docs_root: Path) -> tuple[Path, Path, Path]:
+    prepared = load_json(prepared_path)
+    verification = load_json(verification_path)
+
+    if verification.get("success") is not True:
+        raise ValueError("PublicationVerification is not successful")
+    if verification.get("prepared_release_id") != prepared.get("id"):
+        raise ValueError("PublicationVerification does not reference the supplied PreparedRelease")
+    github_release = verification.get("github_release")
+    if not isinstance(github_release, dict) or github_release.get("published") is not True:
+        raise ValueError("GitHub Release is not confirmed as published")
+
+    version = prepared.get("version")
+    changelog = prepared.get("changelog")
+    if not isinstance(version, str) or VERSION_RE.fullmatch(version) is None:
+        raise ValueError("PreparedRelease contains an invalid version")
+    if not isinstance(changelog, dict) or not isinstance(changelog.get("section"), str):
+        raise ValueError("PreparedRelease does not contain a changelog section")
+
+    major = int(version.split(".", 1)[0])
+    history_root = docs_root / "developer_manual" / "release-history"
+    major_root = history_root / str(major)
+    major_root.mkdir(parents=True, exist_ok=True)
+
+    release_path = major_root / release_filename(version)
+    release_path.write_text(markdown_section_to_rst(changelog["section"], version), encoding="utf-8")
+
+    versions = [path.stem for path in major_root.glob("*.rst") if path.name != "index.rst"]
+    major_index = major_root / "index.rst"
+    major_index.write_text(render_major_index(major, versions), encoding="utf-8")
+
+    majors = [int(path.name) for path in history_root.iterdir() if path.is_dir() and path.name.isdigit()]
+    root_index = history_root / "index.rst"
+    root_index.write_text(render_root_index(majors), encoding="utf-8")
+
+    return release_path, major_index, root_index
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--prepared", required=True, type=Path)
+    parser.add_argument("--verification", required=True, type=Path)
+    parser.add_argument("--docs-root", required=True, type=Path)
+    args = parser.parse_args()
+    release_path, major_index, root_index = synchronize(args.prepared, args.verification, args.docs_root)
+    print(json.dumps({
+        "release_path": str(release_path),
+        "major_index": str(major_index),
+        "root_index": str(root_index),
+    }, separators=(",", ":")))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/tests/test_release_publication_action.py b/tests/test_release_publication_action.py
index 5a1d31e..d341dd6 100644
--- a/tests/test_release_publication_action.py
+++ b/tests/test_release_publication_action.py
@@ -29,6 +29,8 @@ def test_persists_publication_verification_for_downstream_sync(self) -> None:
         content = ACTION.read_text(encoding="utf-8")
         self.assertIn('artifact_name="publication-verification-${RELEASE_ID}"', content)
         self.assertIn("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", content)
+        self.assertIn("prepared-path:", content)
+        self.assertIn("verification-path:", content)
 
 
 if __name__ == "__main__":
diff --git a/tests/test_sync_release_history.py b/tests/test_sync_release_history.py
new file mode 100644
index 0000000..9590a0c
--- /dev/null
+++ b/tests/test_sync_release_history.py
@@ -0,0 +1,79 @@
+# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SCRIPT = ROOT / "scripts" / "sync_release_history.py"
+
+spec = importlib.util.spec_from_file_location("sync_release_history", SCRIPT)
+assert spec is not None and spec.loader is not None
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+
+class SyncReleaseHistoryTest(unittest.TestCase):
+    def test_markdown_release_section_becomes_browsable_rst(self) -> None:
+        section = "## 15.1.0 - 2026-09-21\n\n### Added\n- add feature [#10](https://github.com/LibreSign/libresign/pull/10)\n"
+        rendered = module.markdown_section_to_rst(section, "15.1.0")
+        self.assertIn("15.1.0 - 2026-09-21", rendered)
+        self.assertIn("Added\n-----", rendered)
+        self.assertIn("`#10 `_", rendered)
+
+    def test_synchronization_requires_successful_matching_publication(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            prepared = root / "prepared.json"
+            verification = root / "verification.json"
+            prepared.write_text(json.dumps({
+                "id": "prepared-1",
+                "version": "15.1.0",
+                "changelog": {"section": "## 15.1.0 - 2026-09-21\n\n### Fixed\n- fix one\n"},
+            }), encoding="utf-8")
+            verification.write_text(json.dumps({
+                "success": False,
+                "prepared_release_id": "prepared-1",
+                "github_release": {"published": True},
+            }), encoding="utf-8")
+            with self.assertRaisesRegex(ValueError, "not successful"):
+                module.synchronize(prepared, verification, root / "docs")
+
+    def test_synchronization_is_idempotent_and_indexes_major(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            root = Path(directory)
+            prepared = root / "prepared.json"
+            verification = root / "verification.json"
+            prepared.write_text(json.dumps({
+                "id": "prepared-1",
+                "version": "15.1.0",
+                "changelog": {"section": "## 15.1.0 - 2026-09-21\n\n### Changed\n- update translations\n"},
+            }), encoding="utf-8")
+            verification.write_text(json.dumps({
+                "success": True,
+                "prepared_release_id": "prepared-1",
+                "github_release": {"published": True},
+            }), encoding="utf-8")
+            docs = root / "docs"
+            module.synchronize(prepared, verification, docs)
+            first = (docs / "developer_manual/release-history/15/15.1.0.rst").read_text(encoding="utf-8")
+            module.synchronize(prepared, verification, docs)
+            second = (docs / "developer_manual/release-history/15/15.1.0.rst").read_text(encoding="utf-8")
+            self.assertEqual(first, second)
+            self.assertIn("15.1.0", (docs / "developer_manual/release-history/15/index.rst").read_text(encoding="utf-8"))
+            self.assertIn("LibreSign 15 <15/index>", (docs / "developer_manual/release-history/index.rst").read_text(encoding="utf-8"))
+
+    def test_versions_sort_semantically(self) -> None:
+        versions = ["15.9.0", "15.10.0", "15.10.0-rc.1", "15.2.4"]
+        rendered = module.render_major_index(15, versions)
+        self.assertLess(rendered.index("15.10.0\n"), rendered.index("15.10.0-rc.1\n"))
+        self.assertLess(rendered.index("15.10.0-rc.1\n"), rendered.index("15.9.0\n"))
+
+
+if __name__ == "__main__":
+    unittest.main()