Merge branch 'main' into fix/feedback-terminal-links-20260923

This commit is contained in:
Kuddev authored and GitHub committed 2026-09-23 22:18:17 +08:00
commit 4f55cedda7
26 files changed
+1643 -136

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Plan the native CI matrices before GitHub creates runner jobs.
The output file is the path normally supplied as ``GITHUB_OUTPUT``. Planning
is deliberately separate from workflow steps: a draft pull request therefore
does not materialize jobs for the scarce platforms.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
@dataclass(frozen=True)
class Platform:
runner: str
draft: bool
release_check: bool = False
# One platform catalog owns both draft eligibility and release compilation.
PLATFORMS = (
Platform("ubuntu-24.04", draft=True),
Platform("windows-2022", draft=True),
Platform("macos-26", draft=True, release_check=True),
Platform("windows-11-arm", draft=False),
Platform("macos-26-intel", draft=False, release_check=True),
)
FULL_EVENTS = frozenset(
{"push", "merge_group", "workflow_dispatch", "workflow_call", "schedule"}
)
class PlanError(ValueError):
"""An event cannot be converted into a safe CI plan."""
def _matrices(draft: bool) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
selected = [platform for platform in PLATFORMS if not draft or platform.draft]
return (
[{"os": platform.runner} for platform in selected],
[{"os": platform.runner} for platform in selected if platform.release_check],
)
def _reject_json_constant(value: str) -> None:
raise ValueError(f"non-standard JSON constant {value}")
def _read_event(path: Path) -> dict[str, Any]:
try:
raw = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise PlanError(f"cannot read event payload {path}: {exc}") from exc
try:
payload = json.loads(raw, parse_constant=_reject_json_constant)
except (json.JSONDecodeError, ValueError) as exc:
raise PlanError(f"invalid event JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise PlanError("event payload must be a JSON object")
return payload
def plan_matrices(event_name: str, payload: dict[str, Any]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Return native and release matrices for one validated event."""
if event_name == "pull_request":
pull_request = payload.get("pull_request")
if not isinstance(pull_request, dict):
raise PlanError("pull_request event must contain a pull_request object")
draft = pull_request.get("draft")
if not isinstance(draft, bool):
raise PlanError("pull_request.draft must be a boolean")
return _matrices(draft)
if event_name in FULL_EVENTS:
return _matrices(draft=False)
supported = sorted(FULL_EVENTS | {"pull_request"})
raise PlanError(f"unsupported event name {event_name!r}; expected one of {', '.join(supported)}")
def _compact_output(name: str, value: list[dict[str, str]]) -> str:
return f"{name}={json.dumps(value, separators=(',', ':'), ensure_ascii=True)}"
def write_outputs(path: Path, native_matrix: list[dict[str, str]], release_matrix: list[dict[str, str]]) -> None:
"""Append both outputs in one write after planning has fully succeeded."""
content = "\n".join(
(
_compact_output("native_matrix", native_matrix),
_compact_output("release_matrix", release_matrix),
)
) + "\n"
try:
with path.open("a", encoding="utf-8", newline="\n") as output:
output.write(content)
except (OSError, UnicodeError) as exc:
raise PlanError(f"cannot append CI plan to {path}: {exc}") from exc
def _arguments(argv: Sequence[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--event-name", required=True)
parser.add_argument("--event-path", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
arguments = _arguments(argv)
try:
payload = _read_event(arguments.event_path)
native_matrix, release_matrix = plan_matrices(arguments.event_name, payload)
write_outputs(arguments.output, native_matrix, release_matrix)
except PlanError as exc:
print(f"ci_plan: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -1
View File
@@ -17,7 +17,11 @@ class UpdateProcess {
if (!File.Exists(Path.Combine(directory, "parent-exited"))) return 3;
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_FAIL_INSTALL") == "1") return 17;
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_NOOP_INSTALL") == "1") return 0;
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
try {
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
Directory.CreateDirectory(Path.Combine(target, "runtime"));
File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture");
} catch (IOException) { return 5; }
return 0;
#else
if (args.Length > 0 && args[0] == "--version") {
+48 -1
View File
@@ -11,6 +11,48 @@ from scripts.ci_native_tests import main, native_commands
class NativeSuiteTests(unittest.TestCase):
def test_required_lint_plans_runners_before_native_jobs(self):
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/linux-lua.yml").read_text()
lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0]
self.assertIn("name: lint", lint)
self.assertIn("python scripts/ci_plan.py", lint)
self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint)
self.assertLess(lint.index("cargo fmt"), lint.index("python scripts/ci_plan.py"))
for job, output in (("native-tests", "native_matrix"),
("macos-release-check", "release_matrix")):
body = workflow.split(f"\n {job}:\n", 1)[1]
body = re.split(r"\n [a-z][a-z-]*:\n", body, maxsplit=1)[0]
self.assertIn("needs: lint", body)
self.assertIn(f"fromJSON(needs.lint.outputs.{output})", body)
self.assertNotIn("pull_request.draft", body)
self.assertNotIn("matrix.tier", body)
self.assertIn("cargo check --locked --workspace --release", workflow)
# Native validation never needs to retain checkout credentials.
checkouts = re.findall(r"- uses: actions/checkout@[^\n]+\n(.*?)(?= - |\Z)",
workflow, re.S)
self.assertTrue(checkouts)
for checkout in checkouts:
self.assertIn("persist-credentials: false", checkout)
def test_packages_run_after_merge_or_manual_dispatch_not_for_prs(self):
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/preview-packages.yml").read_text()
events = workflow.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0]
triggers = set(re.findall(r"^ ([a-z_]+):", events, re.M))
self.assertEqual(triggers, {"push", "workflow_dispatch"})
push = re.search(r"^ push:(.*?)(?=^ [a-z_]+:|\Z)", events, re.M | re.S)
self.assertIsNotNone(push)
self.assertIn("branches: [main]", push.group(1))
self.assertIn("paths:", push.group(1))
# Packaging still needs explicit dispatch to create a public release.
self.assertIn("github.event_name == 'workflow_dispatch' && inputs.publish == true", workflow)
stable = (root / ".github/workflows/release.yml").read_text()
stable_events = stable.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0]
self.assertEqual(set(re.findall(r"^ ([a-z_]+):", stable_events, re.M)),
{"push", "workflow_dispatch"})
self.assertIn('tags: ["v*.*.*"]', stable_events)
def test_every_pr_and_merge_group_runs_without_path_exclusions(self):
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/linux-lua.yml").read_text()
@@ -20,7 +62,12 @@ class NativeSuiteTests(unittest.TestCase):
self.assertIsNotNone(declaration)
self.assertNotIn("paths", declaration.group(1))
self.assertNotIn("branches", declaration.group(1))
self.assertNotIn("types", declaration.group(1))
if event == "pull_request":
# ready_for_review starts the scarce-runner jobs that draft
# pushes skip; the default activity types must stay listed.
self.assertIn("types: [opened, synchronize, reopened, ready_for_review]", declaration.group(1))
else:
self.assertNotIn("types", declaration.group(1))
self.assertIn("branches: [main]", events)
self.assertNotIn("branches-ignore", events)
self.assertNotIn("pull_request_target", workflow)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "ci_plan.py"
class CiPlanCliTests(unittest.TestCase):
def run_cli(self, event_name: str, payload: object, output: Path) -> subprocess.CompletedProcess[str]:
event_path = output.parent / "event.json"
event_path.write_text(json.dumps(payload), encoding="utf-8")
return subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
event_name,
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
@staticmethod
def outputs(path: Path) -> dict[str, object]:
lines = path.read_text(encoding="utf-8").splitlines()
return {name: json.loads(value) for name, value in (line.split("=", 1) for line in lines)}
def test_draft_pr_does_not_materialize_scarce_runner_rows(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
result = self.run_cli("pull_request", {"pull_request": {"draft": True}}, output)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
output.read_text(encoding="utf-8"),
'native_matrix=[{"os":"ubuntu-24.04"},{"os":"windows-2022"},{"os":"macos-26"}]\n'
'release_matrix=[{"os":"macos-26"}]\n',
)
plan = self.outputs(output)
self.assertNotIn({"os": "windows-11-arm"}, plan["native_matrix"])
self.assertNotIn({"os": "macos-26-intel"}, plan["native_matrix"])
def test_ready_pr_has_all_native_and_release_checks(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
result = self.run_cli("pull_request", {"pull_request": {"draft": False}}, output)
self.assertEqual(result.returncode, 0, result.stderr)
plan = self.outputs(output)
self.assertEqual(
[row["os"] for row in plan["native_matrix"]],
["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"],
)
self.assertEqual(
[row["os"] for row in plan["release_matrix"]],
["macos-26", "macos-26-intel"],
)
def test_success_appends_outputs_without_replacing_existing_lines(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
output.write_text("existing=preserved\n", encoding="utf-8")
result = self.run_cli("push", {}, output)
self.assertEqual(result.returncode, 0, result.stderr)
lines = output.read_text(encoding="utf-8").splitlines()
self.assertEqual(lines[0], "existing=preserved")
self.assertTrue(lines[1].startswith("native_matrix=[{"))
self.assertTrue(lines[2].startswith("release_matrix=[{"))
def test_merge_group_and_non_pr_events_have_full_coverage(self) -> None:
events = ("merge_group", "push", "workflow_dispatch", "workflow_call", "schedule")
with tempfile.TemporaryDirectory() as directory:
for event_name in events:
with self.subTest(event_name=event_name):
output = Path(directory) / f"{event_name}.out"
result = self.run_cli(event_name, {}, output)
self.assertEqual(result.returncode, 0, result.stderr)
plan = self.outputs(output)
self.assertEqual(
[row["os"] for row in plan["native_matrix"]],
["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"],
)
self.assertEqual(
[row["os"] for row in plan["release_matrix"]],
["macos-26", "macos-26-intel"],
)
def test_invalid_events_fail_without_matrix_outputs(self) -> None:
cases = (
("pull_request", {}, "missing pull request"),
("pull_request", {"pull_request": []}, "wrong pull request type"),
("pull_request", {"pull_request": {}}, "missing draft"),
("pull_request", {"pull_request": {"draft": "false"}}, "wrong draft type"),
("pull_request", {"pull_request": {"draft": 0}}, "integer draft type"),
("unknown", {}, "unknown event"),
("push", [], "non-object payload"),
)
with tempfile.TemporaryDirectory() as directory:
for event_name, payload, label in cases:
with self.subTest(label=label):
output = Path(directory) / f"{label.replace(' ', '-')}.out"
output.write_text("existing=preserved\n", encoding="utf-8")
result = self.run_cli(event_name, payload, output)
self.assertNotEqual(result.returncode, 0)
self.assertIn("ci_plan:", result.stderr)
self.assertEqual(output.read_text(encoding="utf-8"), "existing=preserved\n")
def test_malformed_json_fails_without_creating_output(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
event_path = root / "broken.json"
output = root / "github-output"
event_path.write_text('{"pull_request":', encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
"pull_request",
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(output.exists())
def test_non_standard_json_constant_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
event_path = root / "non-standard.json"
output = root / "github-output"
event_path.write_text('{"value":NaN}', encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
"push",
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(output.exists())
if __name__ == "__main__":
unittest.main()
+16 -2
View File
@@ -16,6 +16,7 @@ from scripts.stable_release import (
)
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"
@@ -78,8 +79,21 @@ class StableReleaseTests(unittest.TestCase):
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)
# 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)
+28 -7
View File
@@ -36,7 +36,7 @@ function Write-Json([string]$Path, $Value) {
}
$results = @()
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'reinstall-noop')) {
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
$directory = Join-Path $OutputRoot $scenario
$installation = Join-Path $directory 'installed app'
$transaction = Join-Path $directory 'transaction'
@@ -44,17 +44,25 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
$null = New-Item -ItemType Directory -Path $installation, $transaction, $config -Force
$executable = Join-Path $installation 'pebrel.exe'
Copy-Item -LiteralPath $old -Destination $executable
$payload = if ($scenario -eq 'reinstall') { $reinstall } else { $candidate }
$payload = if ($scenario -eq 'reinstall') { $reinstall } elseif ($scenario -eq 'repair-identical') { $old } else { $candidate }
Copy-Item -LiteralPath $payload -Destination (Join-Path $directory 'candidate.exe')
[System.IO.File]::WriteAllText((Join-Path $installation 'unins000.exe'), 'fixture marker')
$env:PEBREL_HANDOFF_FIXTURE = $directory
$env:PEBREL_HANDOFF_FAIL_INSTALL = if ($scenario -eq 'installer-failure') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'reinstall-noop') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'upgrade-noop') { '1' } else { '0' }
$parent = Start-Process -FilePath $executable -ArgumentList 'wait' -PassThru
$null = $parent.Handle
$runner = $null
$other = $null
$helperLock = $null
try {
if ($scenario -in @('helper-unlocked', 'helper-held')) {
$null = New-Item -ItemType Directory -Path (Join-Path $installation 'runtime') -Force
$helperPath = Join-Path $installation 'runtime\pebrel-hook.exe'
[System.IO.File]::WriteAllText($helperPath, 'old helper fixture')
$helperLock = [System.IO.FileStream]::new($helperPath, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
}
$plan = @{
schema = 1; transaction = $scenario; executable = $executable; installation = $installation
config_directory = $config; installer = $installer
@@ -64,7 +72,7 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
participants = @(@{ pid = $parent.Id; created = $parent.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() })
}
if ($scenario -eq 'checksum') { $plan.sha256 = '0' * 64 }
if ($scenario -in @('reinstall', 'reinstall-noop')) { $plan.version = '1.8.0' }
if ($scenario -in @('reinstall', 'repair-identical')) { $plan.version = '1.8.0' }
if ($scenario -eq 'creation-time') { $plan.participants[0].created = '1' }
if ($scenario -eq 'other-process') {
$other = Start-Process -FilePath $executable -ArgumentList 'wait-other' -PassThru
@@ -104,8 +112,14 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran while the old process was alive'
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
Assert ($parent.WaitForExit(5000)) 'Fixture parent did not exit'
if ($scenario -eq 'helper-unlocked') {
Start-Sleep -Milliseconds 350
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup started with the helper still locked'
$helperLock.Dispose()
$helperLock = $null
}
Wait-File $resultPath
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'reinstall-noop')) {
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
Wait-File (Join-Path $directory 'new-launch')
$launch = [System.IO.File]::ReadAllLines((Join-Path $directory 'new-launch'))
Assert ($launch[0] -eq $executable) 'Relaunch selected another installation'
@@ -117,24 +131,31 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
}
Assert ($runner.WaitForExit(5000)) 'Helper did not finish'
$result = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json
Assert ($result.success -eq ($scenario -in @('success', 'reinstall'))) 'Unexpected helper outcome'
Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical', 'helper-unlocked'))) 'Unexpected helper outcome'
if ($result.success) {
$expected = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash
Assert ($result.executable_sha256 -eq $expected) 'Installed binary differs from candidate'
Assert (Test-Path -LiteralPath (Join-Path $installation 'runtime\pebrel-hook.exe')) 'Successful setup did not repair the helper'
Assert ([System.IO.File]::ReadAllText((Join-Path $installation 'runtime\pebrel-hook.exe')) -eq 'repaired helper fixture') 'Helper still contains the original payload'
}
if ($scenario -in @('other-process', 'late-process')) {
Assert (-not $other.HasExited) 'Update stopped an unprepared process'
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran with an unprepared process'
}
if ($scenario -in @('installer-failure', 'reinstall-noop')) {
if ($scenario -in @('installer-failure', 'upgrade-noop', 'helper-held')) {
Assert $result.recovered_original 'Untouched old executable was not recovered'
}
if ($scenario -eq 'helper-held') {
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'A blocked helper allowed a partial installation'
Assert ($result.error -match 'pebrel-hook.exe') 'The blocked file is missing from the failure details'
}
$probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
$results += @{ scenario = $scenario; passed = $true }
Write-Output "$scenario : passed"
} finally {
if ($helperLock) { $helperLock.Dispose() }
if ($runner -and -not $runner.HasExited) { $runner.Kill(); $runner.WaitForExit() }
if (-not $parent.HasExited) {
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')