feat(release): publish changelog-driven notes
This commit is contained in:
@@ -21,6 +21,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -144,20 +163,44 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
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 }}
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
# Check if release already exists (idempotent re-runs)
|
RELEASE_BODY="${RUNNER_TEMP}/release-body.md"
|
||||||
EXISTING=$(curl -s -o /dev/null -w '%{http_code}' \
|
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}" \
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}" || true)
|
||||||
if [ "$EXISTING" = "200" ]; then
|
case "${EXISTING}" in
|
||||||
echo "Release v${VERSION} already exists, skipping creation"
|
200)
|
||||||
else
|
echo "Release v${VERSION} exists; resynchronizing its notes"
|
||||||
curl --fail -X POST \
|
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 "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\"}" \
|
-d "${PAYLOAD}" \
|
||||||
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases"
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris${PATH}"
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Attach artifacts to release
|
- name: Attach artifacts to release
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Maintainers add one user-facing entry to Unreleased with each change. A release
|
||||||
|
commit bumps pyproject.toml, renames Unreleased to that bare-semver version and
|
||||||
|
an ISO date, then restores an empty Unreleased section; tag that commit. Do not
|
||||||
|
backfill releases from before this 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.
|
||||||
@@ -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).
|
||||||
Executable
+99
@@ -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<version>{_SEMVER})\] - (?P<date>.+)$", 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())
|
||||||
Executable
+70
@@ -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())
|
||||||
@@ -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"},
|
||||||
|
}
|
||||||
@@ -324,6 +324,20 @@ class TestCIWorkflow:
|
|||||||
assert "upload" in content.lower() or "publish" in content.lower(), \
|
assert "upload" in content.lower() or "publish" in content.lower(), \
|
||||||
"Workflow must include upload/publish step"
|
"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
|
# Tests — Makefile release targets
|
||||||
|
|||||||
Reference in New Issue
Block a user