The release run re-invoked the Full native tests workflow and waited for it before aggregating packages. That workflow already runs for every pull request, merge group and main push, so the repeat added no coverage while its macOS Intel job queued behind the Intel package build and became the critical path (60 minutes end to end, packages done after 28). Aggregation now depends on the packaging jobs only; each package still runs its own runtime conformance and the aggregate still validates every report.
294 lines
14 KiB
Python
294 lines
14 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_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")
|
|
for platform in ("ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"):
|
|
self.assertIn(platform, shared)
|
|
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()
|