333 lines
16 KiB
Python
333 lines
16 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", "scripts/build-installer.ps1",
|
|
"windows-arm64-setup.exe", "--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.9.0", "1.9.1", "1.9.2", "1.10.0", "2.0.0"):
|
|
with self.subTest(version=version):
|
|
names = expected_asset_names(version)
|
|
parsed = tuple(map(int, version.split('.')))
|
|
self.assertEqual(len(names), 9 if parsed >= (1, 9, 2) else 8 if parsed >= (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_post_191_requires_native_windows_arm64_installer(self) -> None:
|
|
for version in ("1.9.0", "1.9.1"):
|
|
with self.subTest(historical_version=version):
|
|
self.assertNotIn(
|
|
f"Pebrel-v{version}-windows-arm64-setup.exe",
|
|
expected_asset_names(version),
|
|
)
|
|
for version in ("1.9.2", "1.10.0", "2.0.0"):
|
|
with self.subTest(version=version):
|
|
self.assertIn(
|
|
f"Pebrel-v{version}-windows-arm64-setup.exe",
|
|
expected_asset_names(version),
|
|
)
|
|
version = "1.9.2"
|
|
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)), 9)
|
|
(root / f"Pebrel-v{version}-windows-arm64-setup.exe").unlink()
|
|
with self.assertRaisesRegex(StableReleaseError, "missing: Pebrel-v1.9.2-windows-arm64-setup.exe"):
|
|
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()
|