diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9f31008..d502090 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -21,6 +21,25 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Validate release tag and notes + run: | + set -euo pipefail + VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" + if [ -z "${VERSION}" ]; then + echo "::error::could not determine the project version" + exit 1 + fi + if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then + EXPECTED_TAG="v${VERSION}" + ACTUAL_TAG="${GITHUB_REF#refs/tags/}" + if [ "${ACTUAL_TAG}" != "${EXPECTED_TAG}" ]; then + echo "::error::tag ${ACTUAL_TAG} does not match ${EXPECTED_TAG}" + exit 1 + fi + fi + python3 scripts/extract_changelog.py CHANGELOG.md "${VERSION}" \ + --footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md" + - name: Set up Python uses: actions/setup-python@v5 with: @@ -144,20 +163,44 @@ jobs: env: GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }} run: | - VERSION=${{ steps.version.outputs.version }} - # Check if release already exists (idempotent re-runs) - EXISTING=$(curl -s -o /dev/null -w '%{http_code}' \ - -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ - "https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}") - if [ "$EXISTING" = "200" ]; then - echo "Release v${VERSION} already exists, skipping creation" - else - curl --fail -X POST \ - -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\"}" \ - "https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases" + set -euo pipefail + if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then + echo "::error::GITEAPACKAGETOKEN repository secret is not configured" + exit 1 fi + VERSION=${{ steps.version.outputs.version }} + RELEASE_BODY="${RUNNER_TEMP}/release-body.md" + if [ ! -s "${RELEASE_BODY}" ]; then + echo "::error::validated release body is missing or empty" + exit 1 + fi + EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json" + EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \ + -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ + "https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}" || true) + case "${EXISTING}" in + 200) + echo "Release v${VERSION} exists; resynchronizing its notes" + REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \ + --body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")" + ;; + 404) + REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \ + --body-file "${RELEASE_BODY}")" + ;; + *) + echo "::error::release lookup failed with HTTP ${EXISTING}" + exit 1 + ;; + esac + METHOD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['method'])")" + PATH="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")" + PAYLOAD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['payload']))")" + curl --fail --silent --show-error -X "${METHOD}" \ + -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}" \ + "https://git.bongbetic.com/api/v1/repos/xavierk/Fenris${PATH}" - name: Attach artifacts to release env: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a41d101 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + + + +## [Unreleased] + +### Added + +- Add Fenris identity and a polkit authentication notice to the dashboard. +- Clarify monitoring continuity, deliberate pauses, and quitting in the dashboard and status output. +- Add per-release notes with installation, verification, and rollback guidance. diff --git a/packaging/release-footer.md b/packaging/release-footer.md new file mode 100644 index 0000000..7d67892 --- /dev/null +++ b/packaging/release-footer.md @@ -0,0 +1,20 @@ +## Install + +Install Fenris from its package channel after following the [package setup instructions](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#install-from-package-recommended): + +```bash +sudo apt update && sudo apt install fenris # Debian / Ubuntu +sudo dnf install fenris # Fedora +sudo zypper install fenris # openSUSE Tumbleweed +``` + +## Verify downloads + +```bash +gpg --verify SHA256SUMS.asc SHA256SUMS +sha256sum -c SHA256SUMS +``` + +## Rollback + +Installing an older package over a newer observation store is unsupported. Restore the observation-store snapshot, then install the earlier Release; see the [upgrade and rollback guidance](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#upgrade). diff --git a/scripts/extract_changelog.py b/scripts/extract_changelog.py new file mode 100755 index 0000000..9ec55d4 --- /dev/null +++ b/scripts/extract_changelog.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Extract one validated Keep a Changelog version section.""" +from __future__ import annotations + +import argparse +from datetime import date +from pathlib import Path +import re +import sys + + +class ChangelogError(ValueError): + """A release cannot safely use the supplied changelog.""" + + +_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" +_VERSION_HEADING = re.compile( + rf"^## \[(?P{_SEMVER})\] - (?P.+)$", re.MULTILINE +) + + +def extract_version_section(changelog: str, version: str) -> str: + """Return *version*'s changelog section without altering its bytes. + + The section ends immediately before the next level-two heading. A release + cannot use an absent, empty, or malformed version section. + """ + if not re.fullmatch(_SEMVER, version): + raise ChangelogError(f"requested version is not bare semver: {version!r}") + + heading = next( + (match for match in _VERSION_HEADING.finditer(changelog) + if match.group("version") == version), + None, + ) + if heading is None: + if re.search(rf"^## \[{re.escape(version)}\].*$", changelog, re.MULTILINE): + raise ChangelogError(f"version {version} has a malformed heading or date") + raise ChangelogError(f"version {version} is missing from the changelog") + + heading_date = heading.group("date") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", heading_date): + raise ChangelogError(f"version {version} has a malformed release date") + try: + date.fromisoformat(heading_date) + except ValueError as error: + raise ChangelogError(f"version {version} has a malformed release date") from error + + next_heading = re.search(r"^## ", changelog[heading.end():], re.MULTILINE) + section_end = heading.end() + next_heading.start() if next_heading else len(changelog) + section = changelog[heading.start():section_end] + if not re.search(r"^- \S", section[heading.end() - heading.start():], re.MULTILINE): + raise ChangelogError(f"version {version} has an empty changelog section") + return section + + +def extract_changelog(path: Path, version: str) -> str: + """Read and extract a requested version from a changelog file.""" + try: + return extract_version_section(path.read_text(encoding="utf-8"), version) + except OSError as error: + raise ChangelogError(f"cannot read changelog {path}: {error.strerror}") from error + + +def assemble_release_body(section: str, footer: str) -> str: + """Append standing guidance while preserving the extracted section verbatim.""" + separator = "\n" if section.endswith("\n") else "\n\n" + return f"{section}{separator}{footer}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("changelog", type=Path) + parser.add_argument("version") + parser.add_argument( + "--footer", + type=Path, + help="append this standing release guidance after the extracted section", + ) + args = parser.parse_args(argv) + try: + section = extract_changelog(args.changelog, args.version) + if args.footer: + try: + footer = args.footer.read_text(encoding="utf-8") + except OSError as error: + raise ChangelogError( + f"cannot read release footer {args.footer}: {error.strerror}" + ) from error + section = assemble_release_body(section, footer) + sys.stdout.write(section) + except ChangelogError as error: + print(f"::error::{error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_request.py b/scripts/release_request.py new file mode 100755 index 0000000..6fc601a --- /dev/null +++ b/scripts/release_request.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Describe the Gitea request that creates or resynchronizes a release.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import sys +from typing import Any + + +_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + + +class ReleaseRequestError(ValueError): + """A release request could not be prepared safely.""" + + +def build_release_request( + version: str, body: str, existing_release: dict[str, Any] | None +) -> dict[str, Any]: + """Return the observable POST or PATCH request for a Gitea release.""" + if not re.fullmatch(_SEMVER, version): + raise ReleaseRequestError(f"version is not bare semver: {version!r}") + if existing_release is None: + return { + "method": "POST", + "path": "/releases", + "payload": {"tag_name": f"v{version}", "name": f"v{version}", "body": body}, + } + + release_id = existing_release.get("id") + if not isinstance(release_id, int): + raise ReleaseRequestError("existing release does not contain an integer id") + return { + "method": "PATCH", + "path": f"/releases/{release_id}", + "payload": {"body": body}, + } + + +def _read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ReleaseRequestError(f"cannot read existing release {path}: {error}") from error + if not isinstance(value, dict): + raise ReleaseRequestError("existing release must be a JSON object") + return value + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--body-file", type=Path, required=True) + parser.add_argument("--existing-release", type=Path) + args = parser.parse_args(argv) + try: + body = args.body_file.read_text(encoding="utf-8") + existing = _read_json(args.existing_release) if args.existing_release else None + print(json.dumps(build_release_request(args.version, body, existing))) + except (OSError, ReleaseRequestError) as error: + print(f"::error::{error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..e23beb7 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,202 @@ +"""Release-note changelog extraction tests (DC-6, DC-7).""" +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parent.parent +EXTRACTOR_PATH = REPO_ROOT / "scripts" / "extract_changelog.py" +RELEASE_REQUEST_PATH = REPO_ROOT / "scripts" / "release_request.py" +CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md" + + +def _extractor_module(): + spec = importlib.util.spec_from_file_location("extract_changelog", EXTRACTOR_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_extracts_the_requested_version_section_verbatim(): + extractor = _extractor_module() + changelog = """# Changelog + +## [Unreleased] + +## [1.4.0] - 2026-09-10 + +### Added + +- Show a release summary to consumers. + +## [1.3.0] - 2026-09-01 + +### Fixed + +- Preserve the observation history during upgrades. +""" + expected = """## [1.4.0] - 2026-09-10 + +### Added + +- Show a release summary to consumers. + +""" + + assert extractor.extract_version_section(changelog, "1.4.0") == expected + + +def test_checked_in_changelog_keeps_unreleased_first_and_categories_limited(): + lines = CHANGELOG_PATH.read_text(encoding="utf-8").splitlines() + unreleased = lines.index("## [Unreleased]") + version_headings = [ + index for index, line in enumerate(lines) + if line.startswith("## [") and line != "## [Unreleased]" + ] + first_version = version_headings[0] if version_headings else len(lines) + categories = [ + line.removeprefix("### ") + for line in lines[unreleased + 1:first_version] + if line.startswith("### ") + ] + + assert unreleased < first_version + assert set(categories) <= {"Added", "Changed", "Fixed"} + + +@pytest.mark.parametrize( + ("changelog", "expected_error"), + [ + ("# Changelog\n\n## [Unreleased]\n", "missing"), + ( + "# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n", + "empty", + ), + ( + "# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-02-30\n\n- Add a note.\n", + "malformed release date", + ), + ], +) +def test_fails_closed_for_missing_empty_or_malformed_sections( + changelog, expected_error +): + extractor = _extractor_module() + + with pytest.raises(extractor.ChangelogError, match=expected_error): + extractor.extract_version_section(changelog, "1.4.0") + + +def test_command_emits_a_workflow_error_and_nonzero_status(tmp_path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text("# Changelog\n\n## [Unreleased]\n", encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(EXTRACTOR_PATH), str(changelog), "1.4.0"], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert result.stderr.startswith("::error::") + assert "missing" in result.stderr + + +def test_assembles_a_release_body_without_changing_the_section(): + extractor = _extractor_module() + section = "## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n" + footer = "## Install\n\nUse the package channel.\n" + + assert extractor.assemble_release_body(section, footer) == ( + section + "\n" + footer + ) + + +def test_command_can_write_the_complete_release_body(tmp_path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n" + "### Added\n\n- Show a release summary.\n", + encoding="utf-8", + ) + footer = tmp_path / "footer.md" + footer.write_text("## Install\n\nUse the package channel.\n", encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + str(EXTRACTOR_PATH), + str(changelog), + "1.4.0", + "--footer", + str(footer), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == ( + "## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n\n" + "## Install\n\nUse the package channel.\n" + ) + + +def test_release_request_command_reports_create_or_patch_decisions(tmp_path): + body = tmp_path / "release-body.md" + body.write_text("## [1.4.0] - 2026-09-10\n", encoding="utf-8") + + create = subprocess.run( + [ + sys.executable, + str(RELEASE_REQUEST_PATH), + "--version", + "1.4.0", + "--body-file", + str(body), + ], + capture_output=True, + text=True, + check=False, + ) + existing = tmp_path / "existing-release.json" + existing.write_text('{"id": 17, "assets": []}', encoding="utf-8") + patch = subprocess.run( + [ + sys.executable, + str(RELEASE_REQUEST_PATH), + "--version", + "1.4.0", + "--body-file", + str(body), + "--existing-release", + str(existing), + ], + capture_output=True, + text=True, + check=False, + ) + + assert create.returncode == patch.returncode == 0 + assert json.loads(create.stdout) == { + "method": "POST", + "path": "/releases", + "payload": { + "tag_name": "v1.4.0", + "name": "v1.4.0", + "body": "## [1.4.0] - 2026-09-10\n", + }, + } + assert json.loads(patch.stdout) == { + "method": "PATCH", + "path": "/releases/17", + "payload": {"body": "## [1.4.0] - 2026-09-10\n"}, + } diff --git a/tests/test_release.py b/tests/test_release.py index 3d08a5d..7943fb3 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -324,6 +324,20 @@ class TestCIWorkflow: assert "upload" in content.lower() or "publish" in content.lower(), \ "Workflow must include upload/publish step" + def test_workflow_validates_notes_before_publication(self): + content = _read(".gitea/workflows/release.yml") + assert "scripts/extract_changelog.py" in content, \ + "Workflow must fail before publication if release notes cannot be extracted" + assert "--footer packaging/release-footer.md" in content, \ + "Workflow must assemble the body from the standing release footer" + + def test_workflow_resynchronizes_existing_release_bodies(self): + content = _read(".gitea/workflows/release.yml") + assert "scripts/release_request.py" in content, \ + "Workflow must make the create-versus-update decision through the request seam" + assert '"${METHOD}"' in content, \ + "Workflow must execute the helper-selected create-or-update request" + # --------------------------------------------------------------------------- # Tests — Makefile release targets