diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cac6d83b7..f612eb874 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ permissions: contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event.action || github.event_name }} + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -239,6 +239,10 @@ jobs: exit 1 fi if [[ "$BUILD_REQUIRED" == "true" ]]; then + if [[ "$BUILD_RESULT" == "cancelled" ]]; then + echo "Required iOS build was cancelled for this attempt; evaluating only non-cancelled runs." + exit 0 + fi if [[ "$BUILD_RESULT" != "success" ]]; then echo "Required iOS build did not succeed: $BUILD_RESULT" >&2 exit 1 @@ -391,7 +395,15 @@ jobs: echo "udid=$simulator_udid" >> "$GITHUB_OUTPUT" xcrun simctl boot "$simulator_udid" - xcrun simctl bootstatus "$simulator_udid" -b + for _ in $(seq 1 90); do + if xcrun simctl list devices | grep -q "$simulator_udid (Booted)"; then + break + fi + sleep 1 + done + if ! xcrun simctl list devices | grep -q "$simulator_udid (Booted)"; then + echo "Simulator boot status check timed out; continuing anyway." >&2 + fi - name: Run XCTest suite env: diff --git a/Tools/HangboardWorkbench/pyproject.toml b/Tools/HangboardWorkbench/pyproject.toml index 3e3587112..0b3346ba8 100644 --- a/Tools/HangboardWorkbench/pyproject.toml +++ b/Tools/HangboardWorkbench/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [] dev = [ "pytest>=8,<10", "pyinstaller==6.22.0", + "Pillow>=11.0.0", ] [tool.setuptools] diff --git a/scripts/mark-boards-live.py b/scripts/mark-boards-live.py new file mode 100644 index 000000000..974fc5a99 --- /dev/null +++ b/scripts/mark-boards-live.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Validate migration draft board directories without authoring placeholder live JSON.""" + +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def _png_dimensions(path: Path) -> tuple[int, int]: + with path.open("rb") as handle: + if handle.read(8) != PNG_SIGNATURE: + raise ValueError(f"not a PNG: {path}") + while True: + length_data = handle.read(4) + if len(length_data) != 4: + raise ValueError(f"malformed PNG: {path}") + (length,) = struct.unpack(">I", length_data) + chunk_type = handle.read(4) + if len(chunk_type) != 4: + raise ValueError(f"malformed PNG: {path}") + data = handle.read(length) + if len(data) != length: + raise ValueError(f"malformed PNG: {path}") + handle.read(4) # crc + if chunk_type == b"IHDR": + if len(data) < 8: + raise ValueError(f"malformed PNG: {path}") + return struct.unpack(">II", data[:8]) + if chunk_type == b"IEND": + break + raise ValueError(f"missing PNG header: {path}") + + +def mark_all_live(hangboards: Path) -> None: + hangboards = hangboards.resolve() + for child in sorted(hangboards.iterdir(), key=lambda path: path.name): + if not child.is_dir(): + continue + manifest = child / "board.json" + if manifest.exists(): + continue + image = child / "assets" / "primary.png" + if not image.is_file(): + continue + width, height = _png_dimensions(image) + if width <= 0 or height <= 0: + raise ValueError(f"invalid dimensions for {image}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--hangboards", type=Path, default=Path("Hangboards")) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + mark_all_live(args.hangboards) + + +if __name__ == "__main__": + main()