Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions actions/release-publication/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions actions/sync-release-history/action.yml
Original file line number Diff line number Diff line change
@@ -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 <noreply@github.com>
author: github-workflows bot <noreply@github.com>
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/**
173 changes: 173 additions & 0 deletions scripts/sync_release_history.py
Original file line number Diff line number Diff line change
@@ -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<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<pre>[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())
2 changes: 2 additions & 0 deletions tests/test_release_publication_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
79 changes: 79 additions & 0 deletions tests/test_sync_release_history.py
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/LibreSign/libresign/pull/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()
Loading