Files
pebrel/scripts/tests/test_stable_release.py
T
kuddev a8e64baf21 chore(release): prepare 1.8.1 metadata and release contracts
Synchronize package versions and bilingual notes with verified issue references.
Allow releases without PR contributors to omit that section, with positive and
negative format coverage as requested by the maintainer.

Keep native update process operations and default-shell resolution inside the
platform boundary. Preserve transaction authority in the updater and tighten
the platform condition budget to the measured 484.
2026-09-15 23:38:21 +08:00

271 lines
13 KiB
Python

from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from scripts.stable_release import (
STABLE_SHA256_PLACEHOLDER,
StableReleaseError,
expected_asset_names,
render_notes,
validate_assets,
validate_changelog,
validate_notes,
verify_release,
)
from scripts.preview_release import sha256
from scripts.preview_release import MIN_ASSET_SIZE
VERSION = "1.6.0"
def write_fake_asset(path: Path) -> None:
if path.suffix == ".exe":
header = b"MZ" + b"\0" * 6
elif path.suffix == ".zip":
header = b"PK\x03\x04" + b"\0" * 4
elif path.name.endswith(".AppImage"):
header = b"\x7fELF\x02\x01\x01\0"
elif path.suffix == ".deb":
header = b"!<arch>\n"
elif path.name.endswith(".tar.gz"):
header = b"\x1f\x8b\x08\0\0\0\0\0"
elif path.suffix == ".dmg":
header = b"\0" * 8
else:
raise AssertionError(path)
path.write_bytes(header + b"\0" * (MIN_ASSET_SIZE - len(header)))
if path.suffix == ".dmg":
with path.open("r+b") as stream:
stream.seek(-512, 2)
stream.write(b"koly")
def notes(checksum_placeholder: bool = True) -> str:
checksum = STABLE_SHA256_PLACEHOLDER if checksum_placeholder else "0" * 64
return f"""# Pebrel {VERSION}
## English
### Added
- A stable package set.
## 中文
### 新增
- 稳定版安装包集合。
## Contributors
<a href=\"https://github.com/Kuddev\"><img src=\"https://github.com/Kuddev.png?size=96\" width=\"64\" height=\"64\" alt=\"@Kuddev avatar\"></a>
## SHA256
{checksum}
"""
class StableReleaseTests(unittest.TestCase):
def test_stable_workflow_requires_full_native_tests_before_aggregation(self) -> None:
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/release.yml").read_text(encoding="utf-8")
native = workflow.split(" native-tests:\n", 1)[1].split("\n linux:\n", 1)[0]
self.assertIn("uses: ./.github/workflows/linux-lua.yml", native)
shared = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8")
for platform in ("ubuntu-24.04", "windows-2022", "macos-26", "macos-26-intel"):
self.assertIn(platform, shared)
self.assertIn("workflow_call:", shared)
self.assertIn("run: python scripts/ci_native_tests.py", shared)
self.assertIn("cargo check --locked --workspace --release", shared)
self.assertIn("tools/i18n-contract/Cargo.toml", shared)
self.assertNotIn("continue-on-error", shared)
self.assertNotIn("continue-on-error", native)
aggregate = workflow.split("\n aggregate:\n", 1)[1].split("\n publish:\n", 1)[0]
self.assertIn("needs: [prepare, native-tests, linux, macos, windows]", aggregate)
self.assertNotIn("always()", aggregate)
def test_native_packagers_expose_stable_channel_without_preview_id(self) -> None:
root = Path(__file__).resolve().parents[2]
linux = (root / "scripts/package-linux.sh").read_text(encoding="utf-8")
macos = (root / "scripts/package-macos.sh").read_text(encoding="utf-8")
for source in (linux, macos):
self.assertIn('--channel', source)
self.assertIn('channel" == "stable"', source)
self.assertIn('stable packages must not include --preview-id', source)
self.assertIn('asset_architecture="x64"', linux)
self.assertIn('asset_architecture="x64"', macos)
self.assertIn('asset_architecture="arm64"', macos)
def test_expected_assets_use_stable_public_names(self) -> None:
names = expected_asset_names(VERSION)
self.assertIn("Pebrel-v1.6.0-linux-x64-preview.AppImage", names)
self.assertIn("Pebrel-v1.6.0-macos-arm64-preview.dmg", names)
self.assertIn("Pebrel-v1.6.0-macos-x64-preview.dmg", names)
self.assertIn("Pebrel-v1.6.0-windows-x64-setup.exe", names)
self.assertIn("NebulaTerminal-1.6.0-windows-x64-setup.exe", names)
self.assertEqual(len(names), 8)
def test_stable_manifest_rejects_prerelease_versions(self) -> None:
with self.assertRaisesRegex(StableReleaseError, "invalid stable version"):
expected_asset_names("1.6.0-rc.1")
def test_17_and_later_assets_use_only_pebrel_installer(self) -> None:
for version in ("1.7.0", "1.7.1", "1.10.0", "2.0.0"):
with self.subTest(version=version):
names = expected_asset_names(version)
self.assertEqual(len(names), 7)
self.assertIn(f"Pebrel-v{version}-windows-x64-setup.exe", names)
self.assertNotIn(f"NebulaTerminal-{version}-windows-x64-setup.exe", names)
def test_17_assets_reject_retired_alias_and_missing_installer(self) -> None:
version = "1.7.0"
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name in expected_asset_names(version):
if name.startswith("Pebrel-"):
write_fake_asset(root / name)
self.assertEqual(len(validate_assets(root, version)), 7)
legacy = root / "NebulaTerminal-1.7.0-windows-x64-setup.exe"
write_fake_asset(legacy)
with self.assertRaisesRegex(StableReleaseError, "unexpected: NebulaTerminal"):
validate_assets(root, version)
legacy.unlink()
(root / "Pebrel-v1.7.0-windows-x64-setup.exe").unlink()
with self.assertRaisesRegex(StableReleaseError, "missing: Pebrel-v1.7.0-windows-x64-setup.exe"):
validate_assets(root, version)
def test_17_notes_require_only_current_asset_names(self) -> None:
version = "1.7.0"
checksums = "\n".join(
f"- `{name}`: `PENDING FINAL BUILD`"
for name in expected_asset_names(version) if name.startswith("Pebrel-")
)
body = notes().replace(VERSION, version).replace(STABLE_SHA256_PLACEHOLDER, checksums)
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "v1.7.0.md"
source.write_text(body, encoding="utf-8")
self.assertEqual(validate_notes(source, version), body)
source.write_text(
body + "- `NebulaTerminal-1.7.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`\n",
encoding="utf-8",
)
with self.assertRaisesRegex(StableReleaseError, "unexpected: NebulaTerminal"):
validate_notes(source, version)
def test_complete_assets_require_identical_legacy_installer(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name in expected_asset_names(VERSION):
write_fake_asset(root / name)
self.assertEqual(len(validate_assets(root, VERSION)), 8)
legacy = root / "NebulaTerminal-1.6.0-windows-x64-setup.exe"
legacy.write_bytes(legacy.read_bytes()[:-1] + b"x")
with self.assertRaisesRegex(StableReleaseError, "byte-identical"):
validate_assets(root, VERSION)
def test_asset_magic_is_checked(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name in expected_asset_names(VERSION):
write_fake_asset(root / name)
(root / "Pebrel-v1.6.0-linux-x64-preview.AppImage").write_bytes(b"bad" + b"\0" * MIN_ASSET_SIZE)
with self.assertRaisesRegex(StableReleaseError, "ELF"):
validate_assets(root, VERSION)
def test_notes_require_bilingual_categories_and_contributor_link(self) -> None:
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "v1.6.0.md"
source.write_text(notes(), encoding="utf-8")
self.assertEqual(validate_notes(source, VERSION), notes())
source.write_text(notes().replace("https://github.com/Kuddev", "https://example.invalid"), encoding="utf-8")
with self.assertRaisesRegex(StableReleaseError, "GitHub links"):
validate_notes(source, VERSION)
def test_notes_allow_no_pr_contributors_but_keep_section_contracts(self) -> None:
before, rest = notes().split("## Contributors\n", 1)
contributor_body, after = rest.split("## SHA256\n", 1)
without_contributors = before + "## SHA256\n" + after
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "notes.md"
source.write_text(without_contributors, encoding="utf-8")
self.assertEqual(validate_notes(source, VERSION), without_contributors)
invalid = (
(before + "## Contributors\n\n## SHA256\n" + after, "GitHub links"),
(notes().replace("## Contributors", "## Contributors\n\n## Contributors"), "at most one"),
(without_contributors + "\n## Contributors\n" + contributor_body, "out of order"),
(without_contributors.replace("### 新增", "### 修复"), "matching bilingual"),
(without_contributors.replace("## 中文", "## Chinese"), "require one"),
)
for body, error in invalid:
with self.subTest(error=error):
source.write_text(body, encoding="utf-8")
with self.assertRaisesRegex(StableReleaseError, error):
validate_notes(source, VERSION)
def test_changelog_must_list_the_same_stable_asset_names(self) -> None:
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "CHANGELOG.md"
source.write_text(
"## 1.6.0 - 2026-09-07\n\n"
+ "\n".join(f"- `{name}`: `PENDING FINAL BUILD`" for name in expected_asset_names(VERSION))
+ "\n",
encoding="utf-8",
)
self.assertTrue(validate_changelog(source, VERSION))
source.write_text(source.read_text(encoding="utf-8").replace("Pebrel-v1.6.0", "Pebrel-1.6.0"), encoding="utf-8")
with self.assertRaisesRegex(StableReleaseError, "missing stable asset"):
validate_changelog(source, VERSION)
def test_render_notes_replaces_placeholder_with_all_hashes(self) -> None:
rendered = render_notes(notes(), "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n")
self.assertNotIn(STABLE_SHA256_PLACEHOLDER, rendered)
self.assertIn("a" * 64 + " Pebrel-v1.6.0-windows-x64.zip", rendered)
def test_render_notes_replaces_pending_named_entries(self) -> None:
source = notes().replace(STABLE_SHA256_PLACEHOLDER, "- `Pebrel-v1.6.0-windows-x64.zip`: `PENDING FINAL BUILD`")
rendered = render_notes(source, "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n")
self.assertIn("`Pebrel-v1.6.0-windows-x64.zip`: `" + "a" * 64, rendered)
def test_render_notes_rejects_stale_explicit_hashes(self) -> None:
with self.assertRaisesRegex(StableReleaseError, "stale or incomplete"):
render_notes(notes(False), "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n")
def test_remote_release_verification_checks_all_assets_and_notes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name in expected_asset_names(VERSION):
write_fake_asset(root / name)
(root / "SHA256SUMS").write_text("checksums\n", encoding="utf-8")
release_notes = notes().replace(STABLE_SHA256_PLACEHOLDER, "rendered checksums")
(root / "RELEASE_NOTES.md").write_text(release_notes, encoding="utf-8")
names = [*expected_asset_names(VERSION), "SHA256SUMS"]
metadata = {
"name": "Pebrel 1.6.0",
"tag_name": "v1.6.0",
"draft": False,
"prerelease": False,
"body": release_notes,
"assets": [
{
"name": name,
"label": "",
"state": "uploaded",
"size": (root / name).stat().st_size,
"digest": f"sha256:{sha256(root / name)}",
}
for name in names
],
}
verify_release(metadata, root, VERSION, "a" * 40, "a" * 40)
metadata["body"] = release_notes.replace("\n", "\r\n")
verify_release(metadata, root, VERSION, "a" * 40, "a" * 40)
metadata["assets"][0]["label"] = "wrong"
with self.assertRaisesRegex(StableReleaseError, "label"):
verify_release(metadata, root, VERSION, "a" * 40, "a" * 40)
if __name__ == "__main__":
unittest.main()