refactor(ci): classify failures and pin webgpu inputs

This commit is contained in:
西风逍遥游 committed 2026-09-03 16:29:23 +08:00
1 parent c39660737e
commit 543aa55434
12 files changed
+456 -5

No files matched your search

+46 -4
View File
@@ -456,7 +456,8 @@ jobs:
run: |
python -m pip install --quiet pillow
python scripts/compare_render_backends.py `
artifacts/render-parity/vulkan artifacts/render-parity/webgpu
artifacts/render-parity/vulkan artifacts/render-parity/webgpu `
--result-json artifacts/render-parity/ci-result.json
- name: Upload render parity artifacts
if: always()
@@ -1495,6 +1496,9 @@ jobs:
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.webgpu == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
EMSCRIPTEN_VERSION: "6.0.9"
EMDAWNWEBGPU_RELEASE: "v20260423.175430"
# Do not add sccache via EM_COMPILER_WRAPPER here: emsdk's Clang commands
# carry -Xclang flags that sccache v0.16 treats as non-cacheable, adding
# wrapper overhead without storing any of the engine's object files.
@@ -1540,7 +1544,7 @@ jobs:
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: $HOME/emsdk
key: emsdk-${{ runner.os }}-${{ runner.arch }}-latest
key: emsdk-${{ runner.os }}-${{ runner.arch }}-${{ env.EMSCRIPTEN_VERSION }}-${{ env.EMDAWNWEBGPU_RELEASE }}-v1
- name: Install Emscripten SDK
timeout-minutes: 30
@@ -1550,8 +1554,46 @@ jobs:
else
git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$HOME/emsdk"
fi
"$HOME/emsdk/emsdk" install latest
"$HOME/emsdk/emsdk" activate latest
"$HOME/emsdk/emsdk" install "$EMSCRIPTEN_VERSION"
"$HOME/emsdk/emsdk" activate "$EMSCRIPTEN_VERSION"
- name: Acquire and verify immutable WebGPU port
timeout-minutes: 10
run: |
set -euo pipefail
mkdir -p artifacts/webgpu-dependencies
archive="artifacts/webgpu-dependencies/emdawnwebgpu.zip"
url="https://github.com/google/dawn/releases/download/${EMDAWNWEBGPU_RELEASE}/emdawnwebgpu_pkg-${EMDAWNWEBGPU_RELEASE}.zip"
if ! curl --fail --location --retry 4 --retry-all-errors --retry-delay 5 \
--output "$archive" "$url"; then
python3 -c 'from pathlib import Path; from scripts.ci_result import CIResult, FailureKind; CIResult.failure(FailureKind.INFRASTRUCTURE, "WEBGPU_DEPENDENCY_DOWNLOAD_FAILED", "webgpu-dependencies", "failed to download the locked emdawnwebgpu archive after retries").write(Path("artifacts/webgpu-dependencies/ci-result.json"))'
exit 1
fi
python3 scripts/verify_webgpu_dependencies.py \
--lock cmake/webgpu_dependencies.json \
--archive "$archive" \
--emscripten-version "$EMSCRIPTEN_VERSION" \
--result-json artifacts/webgpu-dependencies/ci-result.json
- name: Prewarm Emscripten WebGPU port
timeout-minutes: 10
run: |
set -euo pipefail
source "$HOME/emsdk/emsdk_env.sh"
for attempt in 1 2 3; do
em++ --use-port=emdawnwebgpu -x c++ -c /dev/null \
-o artifacts/webgpu-dependencies/port-probe.o && exit 0
[ "$attempt" -lt 3 ] && sleep $((attempt * 10))
done
exit 1
- name: Upload WebGPU dependency result
if: always()
uses: actions/upload-artifact@v7
with:
name: webgpu-dependency-result
path: artifacts/webgpu-dependencies/ci-result.json
if-no-files-found: warn
- name: Configure webgpu (Emscripten/WASM)
timeout-minutes: 15
+10
View File
@@ -0,0 +1,10 @@
{
"schema": "evengine.webgpu-dependencies",
"version": 1,
"emscripten": "6.0.9",
"emdawnwebgpu": {
"release": "v20260423.175430",
"url": "https://github.com/google/dawn/releases/download/v20260423.175430/emdawnwebgpu_pkg-v20260423.175430.zip",
"sha256": "3ad7ebe95a4a20764df46c5a63b0ad489782aee26804fff8511d9797c10e732f"
}
}
+59
View File
@@ -0,0 +1,59 @@
"""Versioned machine-readable outcomes shared by EVEngine CI tools."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from enum import StrEnum
from pathlib import Path
from typing import Any
class FailureKind(StrEnum):
"""Stable top-level CI failure categories; values are public protocol data."""
COMPILE = "compile"
LINK = "link"
CONTRACT = "contract"
TEST_ASSERTION = "test_assertion"
SANITIZER = "sanitizer"
INFRASTRUCTURE = "infrastructure"
PARITY = "parity"
PUBLISH_CONFLICT = "publish_conflict"
CANCELLED_SUPERSEDED = "cancelled_superseded"
@dataclass(frozen=True)
class CIResult:
"""One owning, serializable CI outcome with a stable schema."""
ok: bool
kind: str | None = None
code: str | None = None
subject: str | None = None
message: str | None = None
details: dict[str, Any] | None = None
schema: str = "evengine.ci-result"
version: int = 1
@classmethod
def success(cls, subject: str) -> CIResult:
return cls(ok=True, subject=subject)
@classmethod
def failure(
cls,
kind: FailureKind,
code: str,
subject: str,
message: str,
details: dict[str, Any] | None = None,
) -> CIResult:
return cls(False, kind.value, code, subject, message, details)
def write(self, path: Path) -> None:
"""Atomically publish this result so partial JSON is never observable."""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(asdict(self), indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
+23
View File
@@ -13,6 +13,11 @@ from typing import Any
from PIL import Image
try:
from scripts.ci_result import CIResult, FailureKind
except ModuleNotFoundError: # Direct execution sets sys.path to scripts/.
from ci_result import CIResult, FailureKind
class ArtifactContractError(ValueError):
"""Raised when render-parity producers emitted incomplete artifact sets."""
@@ -185,6 +190,7 @@ def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("reference", type=Path, help="Vulkan artifact directory")
parser.add_argument("candidate", type=Path, help="WebGPU artifact directory")
parser.add_argument("--result-json", type=Path, help="write a versioned CI result")
args = parser.parse_args()
try:
@@ -192,6 +198,13 @@ def main() -> int:
except ArtifactContractError as error:
print("ARTIFACT CONTRACT FAILURE:", file=sys.stderr)
print(error, file=sys.stderr)
if args.result_json:
CIResult.failure(
FailureKind.CONTRACT,
"RENDER_ARTIFACT_CONTRACT_INVALID",
"render-parity",
str(error),
).write(args.result_json)
return 2
failures: list[str] = []
for manifest in manifests:
@@ -199,7 +212,17 @@ def main() -> int:
if failures:
print("PIXEL PARITY FAILURE:", file=sys.stderr)
print("\n".join(f"ERROR: {failure}" for failure in failures), file=sys.stderr)
if args.result_json:
CIResult.failure(
FailureKind.PARITY,
"RENDER_PARITY_EXCEEDED",
"render-parity",
"one or more scenes exceeded their render contract",
{"failures": failures},
).write(args.result_json)
return 1
if args.result_json:
CIResult.success("render-parity").write(args.result_json)
return 0
+37
View File
@@ -0,0 +1,37 @@
import json
import tempfile
import unittest
from pathlib import Path
from scripts.ci_result import CIResult, FailureKind
class CIResultTest(unittest.TestCase):
def test_failure_has_stable_schema_and_kind(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "nested" / "result.json"
CIResult.failure(
FailureKind.PARITY,
"RENDER_MEAN_RGB_EXCEEDED",
"scene",
"mean RGB exceeded contract",
{"expected": 0.1, "actual": 0.2},
).write(path)
payload = json.loads(path.read_text(encoding="utf-8"))
self.assertEqual(payload["schema"], "evengine.ci-result")
self.assertEqual(payload["version"], 1)
self.assertEqual(payload["kind"], "parity")
self.assertEqual(payload["code"], "RENDER_MEAN_RGB_EXCEEDED")
def test_success_has_no_failure_classification(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "result.json"
CIResult.success("render-parity").write(path)
payload = json.loads(path.read_text(encoding="utf-8"))
self.assertTrue(payload["ok"])
self.assertIsNone(payload["kind"])
self.assertIsNone(payload["code"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,51 @@
import hashlib
import json
import tempfile
import unittest
from pathlib import Path
from scripts.verify_webgpu_dependencies import DependencyContractError, verify
class VerifyWebgpuDependenciesTest(unittest.TestCase):
def fixture(self, root: Path) -> tuple[Path, Path]:
archive = root / "port.zip"
archive.write_bytes(b"immutable-port")
lock = root / "lock.json"
lock.write_text(
json.dumps(
{
"schema": "evengine.webgpu-dependencies",
"version": 1,
"emscripten": "6.0.9",
"emdawnwebgpu": {
"release": "v1",
"sha256": hashlib.sha256(b"immutable-port").hexdigest(),
},
}
),
encoding="utf-8",
)
return lock, archive
def test_matching_identity_passes(self):
with tempfile.TemporaryDirectory() as temporary:
lock, archive = self.fixture(Path(temporary))
self.assertEqual(verify(lock, archive, "6.0.9")["version"], 1)
def test_version_drift_is_rejected(self):
with tempfile.TemporaryDirectory() as temporary:
lock, archive = self.fixture(Path(temporary))
with self.assertRaisesRegex(DependencyContractError, "version mismatch"):
verify(lock, archive, "latest")
def test_content_drift_is_rejected(self):
with tempfile.TemporaryDirectory() as temporary:
lock, archive = self.fixture(Path(temporary))
archive.write_bytes(b"tampered")
with self.assertRaisesRegex(DependencyContractError, "SHA-256 mismatch"):
verify(lock, archive, "6.0.9")
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Verify immutable WebGPU toolchain inputs before compilation starts."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
try:
from scripts.ci_result import CIResult, FailureKind
except ModuleNotFoundError: # Direct execution sets sys.path to scripts/.
from ci_result import CIResult, FailureKind
class DependencyContractError(ValueError):
"""The acquired dependency does not match the repository lock."""
def verify(lock_path: Path, archive_path: Path, emscripten_version: str) -> dict:
lock = json.loads(lock_path.read_text(encoding="utf-8"))
if lock.get("schema") != "evengine.webgpu-dependencies" or lock.get("version") != 1:
raise DependencyContractError("unsupported WebGPU dependency lock schema")
if emscripten_version != lock["emscripten"]:
raise DependencyContractError(
f"Emscripten version mismatch: {emscripten_version} != {lock['emscripten']}"
)
actual = hashlib.sha256(archive_path.read_bytes()).hexdigest()
expected = lock["emdawnwebgpu"]["sha256"]
if actual != expected:
raise DependencyContractError(
f"emdawnwebgpu SHA-256 mismatch: {actual} != {expected}"
)
return lock
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--lock", type=Path, required=True)
parser.add_argument("--archive", type=Path, required=True)
parser.add_argument("--emscripten-version", required=True)
parser.add_argument("--result-json", type=Path)
args = parser.parse_args()
try:
lock = verify(args.lock, args.archive, args.emscripten_version)
except (OSError, KeyError, json.JSONDecodeError, DependencyContractError) as error:
if args.result_json:
CIResult.failure(
FailureKind.INFRASTRUCTURE,
"WEBGPU_DEPENDENCY_VERIFICATION_FAILED",
"webgpu-dependencies",
str(error),
).write(args.result_json)
print(f"WEBGPU DEPENDENCY FAILURE: {error}")
return 2
if args.result_json:
CIResult.success("webgpu-dependencies").write(args.result_json)
print(
f"WebGPU dependencies verified: Emscripten {lock['emscripten']}, "
f"emdawnwebgpu {lock['emdawnwebgpu']['release']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
#pragma once
/**
* @file DiagnosticValue.h
* @brief Compile-time contract for stable names and codes of domain values.
*/
#include "common/Diagnostic.h"
#include "common/Status.h"
#include <concepts>
#include <string_view>
#include <type_traits>
namespace eve {
/** @brief Customization point for values that cross diagnostic boundaries. */
template <class T>
struct DiagnosticValueTraits;
/**
* @brief A value with a stable protocol name and machine-readable diagnostic code.
* @note Specializations must be pure, thread-safe, and return static text.
*/
template <class T>
concept DiagnosticValue = std::is_enum_v<T> && requires(T value) {
{ DiagnosticValueTraits<T>::name(value) } noexcept -> std::convertible_to<std::string_view>;
{ DiagnosticValueTraits<T>::code(value) } noexcept -> std::same_as<DiagnosticCode>;
};
/** @brief Return the stable name supplied by a DiagnosticValue specialization. */
template <DiagnosticValue T>
[[nodiscard]] constexpr std::string_view diagnosticValueName(T value) noexcept {
return DiagnosticValueTraits<T>::name(value);
}
/** @brief Return the stable machine-readable code supplied by a DiagnosticValue specialization. */
template <DiagnosticValue T>
[[nodiscard]] constexpr DiagnosticCode diagnosticValueCode(T value) noexcept {
return DiagnosticValueTraits<T>::code(value);
}
/** @brief DiagnosticValue mapping for diagnostic severities. */
template <>
struct DiagnosticValueTraits<Severity> {
static constexpr std::string_view name(Severity value) noexcept { return severityName(value); }
static constexpr DiagnosticCode code(Severity value) noexcept {
return value == Severity::Error || value == Severity::Fatal ? DiagnosticCode::Failed : DiagnosticCode::None;
}
};
/** @brief DiagnosticValue mapping for machine-readable diagnostic codes. */
template <>
struct DiagnosticValueTraits<DiagnosticCode> {
static constexpr std::string_view name(DiagnosticCode value) noexcept { return diagnosticCodeName(value); }
static constexpr DiagnosticCode code(DiagnosticCode value) noexcept { return value; }
};
/** @brief DiagnosticValue mapping for operation status categories. */
template <>
struct DiagnosticValueTraits<StatusCode> {
static constexpr std::string_view name(StatusCode value) noexcept { return statusCodeName(value); }
static constexpr DiagnosticCode code(StatusCode value) noexcept {
switch (value) {
case StatusCode::Ok:
case StatusCode::Applied:
case StatusCode::NoOp:
case StatusCode::Pending: return DiagnosticCode::None;
case StatusCode::Rejected: return DiagnosticCode::PreconditionViolation;
case StatusCode::Conflict: return DiagnosticCode::Conflict;
case StatusCode::NotFound: return DiagnosticCode::NotFound;
case StatusCode::Unsupported: return DiagnosticCode::Unsupported;
case StatusCode::Cancelled: return DiagnosticCode::Cancelled;
case StatusCode::Failed: return DiagnosticCode::Failed;
}
return DiagnosticCode::Failed;
}
};
} // namespace eve
+48
View File
@@ -0,0 +1,48 @@
#pragma once
/**
* @file ProtocolDispatch.h
* @brief Exhaustive outcomes for one LSP, DAP, or MCP input message.
*/
#include "common/DiagnosticValue.h"
#include "common/Result.h"
#include <string_view>
namespace eve {
/** @brief Observable disposition of one protocol input message. */
enum class ProtocolDispatch {
ReplySent,
NotificationHandled,
Rejected,
Terminate,
};
/**
* @brief Structured result returned by protocol dispatch boundaries.
* @note Transport/parse failures belong in the Result status; successful
* values describe every valid protocol disposition exhaustively.
*/
using ProtocolDispatchResult = Result<ProtocolDispatch>;
/** @brief DiagnosticValue mapping for protocol dispatch outcomes. */
template <>
struct DiagnosticValueTraits<ProtocolDispatch> {
static constexpr std::string_view name(ProtocolDispatch value) noexcept {
switch (value) {
case ProtocolDispatch::ReplySent: return "reply_sent";
case ProtocolDispatch::NotificationHandled: return "notification_handled";
case ProtocolDispatch::Rejected: return "rejected";
case ProtocolDispatch::Terminate: return "terminate";
}
return "unknown";
}
static constexpr DiagnosticCode code(ProtocolDispatch value) noexcept {
return value == ProtocolDispatch::Rejected ? DiagnosticCode::PreconditionViolation : DiagnosticCode::None;
}
};
} // namespace eve
+20
View File
@@ -0,0 +1,20 @@
#include "common/DiagnosticValue.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
namespace {
enum class UnregisteredValue { Value };
}
static_assert(eve::DiagnosticValue<eve::Severity>);
static_assert(eve::DiagnosticValue<eve::DiagnosticCode>);
static_assert(eve::DiagnosticValue<eve::StatusCode>);
static_assert(!eve::DiagnosticValue<UnregisteredValue>);
TEST_CASE("common.diagnosticValue.stableNamesAndCodes") {
CHECK_EQ(eve::diagnosticValueName(eve::Severity::Fatal), std::string_view("fatal"));
CHECK_EQ(eve::diagnosticValueCode(eve::Severity::Fatal), eve::DiagnosticCode::Failed);
CHECK_EQ(eve::diagnosticValueName(eve::StatusCode::Conflict), std::string_view("conflict"));
CHECK_EQ(eve::diagnosticValueCode(eve::StatusCode::Conflict), eve::DiagnosticCode::Conflict);
}
+14
View File
@@ -0,0 +1,14 @@
#include "common/ProtocolDispatch.h"
#include "zeroerr/unittest.h"
static_assert(eve::DiagnosticValue<eve::ProtocolDispatch>);
TEST_CASE("common.protocolDispatch.hasExhaustiveStableOutcomes") {
CHECK_EQ(eve::diagnosticValueName(eve::ProtocolDispatch::ReplySent), std::string_view("reply_sent"));
CHECK_EQ(eve::diagnosticValueName(eve::ProtocolDispatch::NotificationHandled),
std::string_view("notification_handled"));
CHECK_EQ(eve::diagnosticValueName(eve::ProtocolDispatch::Rejected), std::string_view("rejected"));
CHECK_EQ(eve::diagnosticValueName(eve::ProtocolDispatch::Terminate), std::string_view("terminate"));
CHECK_EQ(eve::diagnosticValueCode(eve::ProtocolDispatch::Rejected), eve::DiagnosticCode::PreconditionViolation);
}