Files
pebrel/scripts/tests/test_stable_release.py
T

308 lines
15 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
from scripts.ci_plan import plan_matrices
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_packages_without_repeating_native_tests(self) -> None:
# The Full native tests workflow already covers every PR, merge group
# and main push; the release run only packages and verifies the runtime
# conformance evidence of each package, so its wall time is bounded by
# the slowest build rather than by test scheduling.
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/release.yml").read_text(encoding="utf-8")
self.assertNotIn(" native-tests:\n", workflow)
self.assertNotIn("uses: ./.github/workflows/linux-lua.yml", workflow)
shared = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8")
# Platform coverage comes from the event plan, not literal runner names
# in YAML. Verify both the consumer wiring and the full caller matrices.
for output in ("native_matrix", "release_matrix"):
self.assertIn(f"fromJSON(needs.lint.outputs.{output})", shared)
self.assertIn("runs-on: ${{ matrix.os }}", shared)
for event in ("push", "merge_group", "workflow_call", "workflow_dispatch"):
with self.subTest(event=event):
native, release = plan_matrices(event, {})
self.assertCountEqual(
[row["os"] for row in native],
["ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"],
)
self.assertCountEqual(
[row["os"] for row in release], ["macos-26", "macos-26-intel"],
)
for trigger in ("pull_request:", "merge_group:", "branches: [main]"):
self.assertIn(trigger, 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)
aggregate = workflow.split("\n aggregate:\n", 1)[1].split("\n publish:\n", 1)[0]
self.assertIn("needs: [prepare, linux, macos, windows, windows-arm64]", aggregate)
self.assertIn("windows_arm64=True", aggregate)
arm = workflow.split("\n windows-arm64:\n", 1)[1].split("\n aggregate:\n", 1)[0]
for required in ("runs-on: windows-11-arm", "host: aarch64-pc-windows-msvc",
"-Architecture arm64", "windows-arm64-report.json",
"scripts/build-windows-product.ps1", "--platform windows-aarch64"):
self.assertIn(required, arm)
self.assertNotIn("continue-on-error", arm)
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), 8 if tuple(map(int, version.split('.'))) >= (1, 9, 0) else 7)
self.assertIn(f"Pebrel-v{version}-windows-x64-setup.exe", names)
self.assertNotIn(f"NebulaTerminal-{version}-windows-x64-setup.exe", names)
def test_19_requires_native_windows_arm64_without_changing_historical_assets(self) -> None:
self.assertNotIn("Pebrel-v1.8.2-windows-arm64.zip", expected_asset_names("1.8.2"))
version = "1.9.0"
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)
(root / "Pebrel-v1.9.0-windows-arm64.zip").unlink()
with self.assertRaisesRegex(StableReleaseError, "missing: Pebrel-v1.9.0-windows-arm64.zip"):
validate_assets(root, version)
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()