diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index be177372..8c9b9f38 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -125,7 +125,7 @@ jobs: if: needs.detect-version.outputs.should_release == 'true' uses: ./.github/workflows/_build.yml with: - runs-on: macos-14 + runs-on: macos-15-intel rust-target: x86_64-apple-darwin build-macos-arm: diff --git a/.github/workflows/macos-x86-build.yml b/.github/workflows/macos-x86-build.yml index 5c233596..207b0438 100644 --- a/.github/workflows/macos-x86-build.yml +++ b/.github/workflows/macos-x86-build.yml @@ -24,10 +24,9 @@ jobs: uses: ./.github/workflows/_build.yml with: source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - runs-on: macos-14 + runs-on: macos-15-intel # Explicit target so CI wheels/binaries are identical to the release # builds in auto-release.yml (same artifact names, plat-name tags, and - # soldr cache key), letting auto-release reuse them (#170). Also fixes - # a latent bug: macos-14 runners are Apple Silicon, so the previous - # native build produced an arm64 binary under the "macOS x86" name. + # soldr cache key), letting auto-release reuse them (#170). + # The Intel host also executes x86 tests on the architecture we ship. rust-target: x86_64-apple-darwin diff --git a/.github/workflows/macos-x86-integration-test.yml b/.github/workflows/macos-x86-integration-test.yml index 07a33e5d..d7ac8a84 100644 --- a/.github/workflows/macos-x86-integration-test.yml +++ b/.github/workflows/macos-x86-integration-test.yml @@ -24,4 +24,4 @@ jobs: uses: ./.github/workflows/_integration-test.yml with: source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - runs-on: macos-14 + runs-on: macos-15-intel diff --git a/.github/workflows/macos-x86-lint.yml b/.github/workflows/macos-x86-lint.yml index b63d273e..5b8b19bb 100644 --- a/.github/workflows/macos-x86-lint.yml +++ b/.github/workflows/macos-x86-lint.yml @@ -24,4 +24,4 @@ jobs: uses: ./.github/workflows/_lint.yml with: source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - runs-on: macos-14 + runs-on: macos-15-intel diff --git a/.github/workflows/macos-x86-unit-test.yml b/.github/workflows/macos-x86-unit-test.yml index c0b7ceb9..0daf6cd3 100644 --- a/.github/workflows/macos-x86-unit-test.yml +++ b/.github/workflows/macos-x86-unit-test.yml @@ -24,4 +24,4 @@ jobs: uses: ./.github/workflows/_unit-test.yml with: source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - runs-on: macos-14 + runs-on: macos-15-intel diff --git a/ci/verify_full_coverage.py b/ci/verify_full_coverage.py index baeeee4d..3e899555 100644 --- a/ci/verify_full_coverage.py +++ b/ci/verify_full_coverage.py @@ -10,7 +10,14 @@ from pathlib import Path MANIFEST = Path(__file__).with_name("full_coverage.json") -PLATFORMS = {"linux-x86", "linux-arm", "windows-x86", "windows-arm", "macos-x86", "macos-arm"} +PLATFORMS = { + "linux-x86", + "linux-arm", + "windows-x86", + "windows-arm", + "macos-x86", + "macos-arm", +} def get_json(path): @@ -48,12 +55,19 @@ def validate_report(report, manifest, sha): return False if any(not checks for checks in tests.values()): return False - if any(not check["workflow"].startswith(platform + "-") for platform, checks in tests.items() for check in checks): + if any( + not check["workflow"].startswith(platform + "-") + for platform, checks in tests.items() + for check in checks + ): return False cells = report.get("cells", {}) if set(cells) != set(names): return False - if not all(cell.get("result") == "passed" and isinstance(cell.get("run_id"), int) for cell in cells.values()): + if not all( + cell.get("result") == "passed" and isinstance(cell.get("run_id"), int) + for cell in cells.values() + ): return False if report.get("test_execution_gaps") != []: return False @@ -62,7 +76,13 @@ def validate_report(report, manifest, sha): if check["workflow"] not in cells: return False jobs = cells[check["workflow"]].get("jobs", []) - if not any(step.get("name") == check["step"] and step.get("conclusion") == "success" for job in jobs if job.get("conclusion") == "success" for step in job.get("steps", [])): + if not any( + step.get("name") == check["step"] + and step.get("conclusion") == "success" + for job in jobs + if job.get("conclusion") == "success" + for step in job.get("steps", []) + ): return False return True @@ -77,18 +97,26 @@ def main(): if manifest.get("schema_version") != 2: parser.error("unsupported coverage manifest") repo = os.environ["GITHUB_REPOSITORY"] - runs = get_json(f"repos/{repo}/actions/workflows/full-coverage.yml/runs?head_sha={args.sha}&event=workflow_dispatch&per_page=100")["workflow_runs"] + runs = get_json( + f"repos/{repo}/actions/workflows/full-coverage.yml/runs?head_sha={args.sha}&event=workflow_dispatch&per_page=100" + )["workflow_runs"] for run in runs: if run["head_sha"] != args.sha or run["conclusion"] != "success": continue - artifacts = get_json(f"repos/{repo}/actions/runs/{run['id']}/artifacts?per_page=100")["artifacts"] + artifacts = get_json( + f"repos/{repo}/actions/runs/{run['id']}/artifacts?per_page=100" + )["artifacts"] for artifact in artifacts: if artifact["name"] != f"full-coverage-{run['id']}" or artifact["expired"]: continue - with zipfile.ZipFile(io.BytesIO(download(artifact["archive_download_url"]))) as archive: + with zipfile.ZipFile( + io.BytesIO(download(artifact["archive_download_url"])) + ) as archive: report = json.loads(archive.read("full-coverage.json")) if validate_report(report, manifest, args.sha): - print(f"Verified all {len(manifest['workflows'])} platform workflows: {run['html_url']}") + print( + f"Verified all {len(manifest['workflows'])} platform workflows: {run['html_url']}" + ) return 0 print("No passing exact-SHA release full-coverage report found", file=sys.stderr) return 1 diff --git a/ci/vscode_release_artifact_lint.py b/ci/vscode_release_artifact_lint.py index 154aa60c..3d15af9b 100644 --- a/ci/vscode_release_artifact_lint.py +++ b/ci/vscode_release_artifact_lint.py @@ -1,6 +1,7 @@ """Validate the complete VS Code release payload before creating its tag.""" import argparse +import hashlib import json import zipfile from pathlib import Path @@ -16,28 +17,60 @@ "universal", } ) +NATIVE_TARGETS = TARGETS - {"universal"} def check(artifacts: Path, version: str) -> list[Path]: - expected = {f"fastled-wasm-{version}-{target}.vsix" for target in TARGETS} + expected_packages = {f"fastled-wasm-{version}-{target}.vsix" for target in TARGETS} + expected_sidecars = { + f"{target}.{suffix}" + for target in NATIVE_TARGETS + for suffix in ("manifest.json", "sha256", "size") + } + expected = expected_packages | expected_sidecars found = {path.name for path in artifacts.iterdir() if path.is_file()} if found != expected: raise ValueError( - f"expected exact seven versioned VSIX files: {sorted(expected)}; got {sorted(found)}" + f"expected seven VSIX packages and their native sidecars: {sorted(expected)}; got {sorted(found)}" ) - packages = sorted(artifacts / name for name in expected) + packages = sorted(artifacts / name for name in expected_packages) for package in packages: if package.stat().st_size == 0: raise ValueError(f"empty VSIX: {package.name}") + embedded_manifest: bytes | None = None try: with zipfile.ZipFile(package) as archive: if archive.testzip() is not None: raise ValueError(f"corrupt VSIX: {package.name}") manifest = json.loads(archive.read("extension/package.json")) + target = next( + target + for target in TARGETS + if package.name.endswith(f"-{target}.vsix") + ) + if target in NATIVE_TARGETS: + embedded_manifest = archive.read( + "extension/resources/clangd/manifest.json" + ) except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as error: raise ValueError(f"invalid VSIX: {package.name}: {error}") from error if manifest.get("name") != "fastled-wasm" or manifest.get("version") != version: raise ValueError(f"VSIX manifest mismatch: {package.name}") + if target in NATIVE_TARGETS: + if embedded_manifest is None: + raise ValueError(f"missing native manifest: {package.name}") + expected_hash = ( + f"{hashlib.sha256(package.read_bytes()).hexdigest()} {package.name}\n" + ) + expected_size = f"{package.stat().st_size}\n" + if ( + artifacts / f"{target}.manifest.json" + ).read_bytes() != embedded_manifest: + raise ValueError(f"native manifest sidecar mismatch: {package.name}") + if (artifacts / f"{target}.sha256").read_text() != expected_hash: + raise ValueError(f"SHA-256 sidecar mismatch: {package.name}") + if (artifacts / f"{target}.size").read_text() != expected_size: + raise ValueError(f"size sidecar mismatch: {package.name}") return packages diff --git a/tests/unit/test_ci_modes.py b/tests/unit/test_ci_modes.py index c927817d..205a9389 100644 --- a/tests/unit/test_ci_modes.py +++ b/tests/unit/test_ci_modes.py @@ -1,5 +1,6 @@ """Guard the event-to-CI-tier wiring across every platform workflow.""" +import hashlib import importlib.util import json import zipfile @@ -95,16 +96,31 @@ def test_vscode_release_artifact_lint_rejects_missing_corrupt_and_wrong_version( module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) targets = { - "win32-x64", "win32-arm64", "linux-x64", "linux-arm64", - "darwin-x64", "darwin-arm64", "universal", + "win32-x64", + "win32-arm64", + "linux-x64", + "linux-arm64", + "darwin-x64", + "darwin-arm64", + "universal", } for target in targets: - with zipfile.ZipFile( - tmp_path / f"fastled-wasm-1.0.1-{target}.vsix", "w" - ) as archive: + package = tmp_path / f"fastled-wasm-1.0.1-{target}.vsix" + with zipfile.ZipFile(package, "w") as archive: archive.writestr( - "extension/package.json", json.dumps({"name": "fastled-wasm", "version": "1.0.1"}) + "extension/package.json", + json.dumps({"name": "fastled-wasm", "version": "1.0.1"}), ) + if target != "universal": + archive.writestr( + "extension/resources/clangd/manifest.json", b"native manifest" + ) + if target != "universal": + (tmp_path / f"{target}.manifest.json").write_bytes(b"native manifest") + (tmp_path / f"{target}.sha256").write_text( + f"{hashlib.sha256(package.read_bytes()).hexdigest()} {package.name}\n" + ) + (tmp_path / f"{target}.size").write_text(f"{package.stat().st_size}\n") assert len(module.check(tmp_path, "1.0.1")) == 7 with pytest.raises(ValueError): module.check(tmp_path, "1.0.2") @@ -113,6 +129,41 @@ def test_vscode_release_artifact_lint_rejects_missing_corrupt_and_wrong_version( module.check(tmp_path, "1.0.1") +def test_vscode_release_artifact_lint_accepts_native_sidecars_only(tmp_path): + spec = importlib.util.spec_from_file_location( + "vscode_release_artifact_lint", ROOT / "ci" / "vscode_release_artifact_lint.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + version = "1.0.1" + for target in module.TARGETS: + package = tmp_path / f"fastled-wasm-{version}-{target}.vsix" + with zipfile.ZipFile(package, "w") as archive: + archive.writestr( + "extension/package.json", + json.dumps({"name": "fastled-wasm", "version": version}), + ) + if target != "universal": + archive.writestr( + "extension/resources/clangd/manifest.json", b"native manifest" + ) + if target != "universal": + (tmp_path / f"{target}.manifest.json").write_bytes(b"native manifest") + (tmp_path / f"{target}.sha256").write_text( + f"{hashlib.sha256(package.read_bytes()).hexdigest()} {package.name}\n" + ) + (tmp_path / f"{target}.size").write_text(f"{package.stat().st_size}\n") + assert len(module.check(tmp_path, version)) == 7 + (tmp_path / "unexpected.txt").write_text("extra") + with pytest.raises(ValueError, match="unexpected"): + module.check(tmp_path, version) + (tmp_path / "unexpected.txt").unlink() + (tmp_path / "linux-x64.sha256").write_text("incorrect digest\n") + with pytest.raises(ValueError, match="SHA-256 sidecar mismatch"): + module.check(tmp_path, version) + + def test_release_does_not_wait_for_removed_routine_build_artifacts(): workflow = (WORKFLOWS / "auto-release.yml").read_text() assert "collect-artifacts:" not in workflow diff --git a/tests/unit/test_kernal_boundary.py b/tests/unit/test_kernal_boundary.py index 53073bdf..651ca72b 100644 --- a/tests/unit/test_kernal_boundary.py +++ b/tests/unit/test_kernal_boundary.py @@ -217,7 +217,10 @@ def test_kernal_api_is_the_only_rust_dependency(): # A `kernal-api/` entry in [features] would reach the # build-dependency too, so runtime features are named on the dependency. for name, enables in package["features"].items(): - assert not any(entry.startswith("kernal-api/") for entry in enables), (name, enables) + assert not any(entry.startswith("kernal-api/") for entry in enables), ( + name, + enables, + ) build_rs = (root / "crates/fastled-cli/build.rs").read_text() # Leading path segments only: `kernal_api::build_resources::...` names # the crate `kernal_api`, not a crate called `build_resources`.