diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03464c5..c2c41dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: # back to their defaults. PYTHONOS_BUILD_JOBS: ${{ matrix.build_jobs }} PYTHONOS_GUI_COMMAND_TIMEOUT: ${{ matrix.gui_command_timeout }} + PYTHONOS_GUI_FRAME_TIMEOUT: ${{ matrix.gui_frame_timeout }} strategy: fail-fast: false matrix: @@ -51,6 +52,7 @@ jobs: # Linux hosts. build_jobs: "1" gui_command_timeout: "240" + gui_frame_timeout: "240" qemu_bin: qemu-system-x86_64 artifact: pythonos-macos-x86_64 artifact_path: build/pythonos.iso diff --git a/scripts/release.sh b/scripts/release.sh index 6971c72..940befb 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -235,6 +235,34 @@ collect_ci_images() { info "arm64 ELF: $RELEASE_ELF" } +# Wrap a CI image in the versioned, checksummed bundle the sibling projects +# publish. RubyOS and RemoteOS-SDL ship --.tar.gz plus +# a .sha256; PythonOS was attaching bare pythonos.iso / pythonos-arm64.elf with +# no version in the name and nothing to verify them against. The images here are +# guest media rather than host runtimes, so the platform field is the guest +# architecture and there are two of them, not three. +package_release_image() { + local image="$1" arch="$2" version="$3" dest="$4" + local bundle="pythonos-${version}-${arch}" + local stage + stage="$(mktemp -d "${TMPDIR:-/tmp}/pythonos-bundle.XXXXXX")" + mkdir -p "$stage/$bundle" + cp -f "$image" README.md LICENSE RELEASE-NOTES.md "$stage/$bundle/" + printf 'version=%s\ntarget=%s\nimage=%s\n' \ + "$version" "$arch" "$(basename "$image")" > "$stage/$bundle/BUILD-INFO.txt" + tar -czf "$dest/$bundle.tar.gz" -C "$stage" "$bundle" + ( + cd "$dest" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$bundle.tar.gz" + else + shasum -a 256 "$bundle.tar.gz" + fi + ) > "$dest/$bundle.tar.gz.sha256" + rm -rf "$stage" + printf '%s/%s.tar.gz\n' "$dest" "$bundle" +} + main() { local bump="${1:-patch}" local previous @@ -291,9 +319,17 @@ main() { info "creating GitHub release $tag" info "attaching CI images: $RELEASE_ISO $RELEASE_ELF" + local bundle_dir="$ASSET_DIR/bundles" + mkdir -p "$bundle_dir" + local x86_bundle arm_bundle + x86_bundle="$(package_release_image "$RELEASE_ISO" x86_64 "$version" "$bundle_dir")" + arm_bundle="$(package_release_image "$RELEASE_ELF" arm64 "$version" "$bundle_dir")" + info "packaged $x86_bundle" + info "packaged $arm_bundle" + gh release create "$tag" --title "$tag" --notes-file "$notes_file" \ - "$RELEASE_ISO#pythonos.iso" \ - "$RELEASE_ELF#pythonos-arm64.elf" + "$x86_bundle" "$x86_bundle.sha256" \ + "$arm_bundle" "$arm_bundle.sha256" info "release complete: $tag" } diff --git a/tests/ci_gate_test.py b/tests/ci_gate_test.py index 0f2658b..18627bf 100644 --- a/tests/ci_gate_test.py +++ b/tests/ci_gate_test.py @@ -119,9 +119,15 @@ def main() -> int: "did not upload pythonos.iso" in release) check("release.sh requires the arm64 ELF", "did not upload pythonos-arm64.elf" in release) - check("release.sh attaches both images", - '"$RELEASE_ISO#pythonos.iso"' in release - and '"$RELEASE_ELF#pythonos-arm64.elf"' in release) + check("release.sh packages both images as versioned bundles", + 'package_release_image "$RELEASE_ISO" x86_64' in release + and 'package_release_image "$RELEASE_ELF" arm64' in release + and 'bundle="pythonos-${version}-${arch}"' in release) + check("release.sh attaches a checksum beside every bundle", + '"$x86_bundle" "$x86_bundle.sha256"' in release + and '"$arm_bundle" "$arm_bundle.sha256"' in release) + check("release bundles carry the docs the other projects ship", + 'cp -f "$image" README.md LICENSE RELEASE-NOTES.md' in release) check("release requires current long-form release notes", "validate_release_notes" in release and "# PythonOS v$version" in release diff --git a/tests/desktop_smoke_test.py b/tests/desktop_smoke_test.py index 4dce8f5..c47e442 100644 --- a/tests/desktop_smoke_test.py +++ b/tests/desktop_smoke_test.py @@ -22,7 +22,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from qmp_helper import ( QemuMonitor, parse_ppm, sample_pixel, color_close, - tile_hashes, golden_check_or_refresh, + tile_hashes, golden_check_or_refresh, env_seconds, ) @@ -31,7 +31,12 @@ MON = "/tmp/pythonos-desktop.mon.sock" PPM = "/tmp/pythonos-desktop.ppm" LOG = "/tmp/pythonos-desktop.log" -BOOT_TIMEOUT = float(os.environ.get("PYTHONOS_GUI_BOOT_TIMEOUT", "30")) +BOOT_TIMEOUT = env_seconds("PYTHONOS_GUI_BOOT_TIMEOUT", 30.0) +# How long to wait for the desktop's signature pixels to appear. Startup +# imports the app registry and decodes the background inside the guest, which +# under TCG on a VM-hosted runner can take several times what it costs on a +# Linux host. +FRAME_TIMEOUT = env_seconds("PYTHONOS_GUI_FRAME_TIMEOUT", 60.0) def _qemu_cmd(): @@ -99,7 +104,7 @@ def main() -> int: # background in the guest. Under TCG that takes much longer than # under KVM, so wait for the window's signature pixels instead of # assuming a fixed four-second startup time. - frame_deadline = time.time() + 60.0 + frame_deadline = time.time() + FRAME_TIMEOUT w = h = 0 rgb = b"" while time.time() < frame_deadline: diff --git a/tests/gui_smoke_test.py b/tests/gui_smoke_test.py index a064e57..5bf81d4 100644 --- a/tests/gui_smoke_test.py +++ b/tests/gui_smoke_test.py @@ -28,29 +28,13 @@ # Allow `python3 tests/gui_smoke_test.py` from the repo root or anywhere. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from qmp_helper import ( - QemuMonitor, parse_ppm, sample_pixel, color_close, + QemuMonitor, parse_ppm, sample_pixel, color_close, env_seconds, ) -def _env_seconds(name: str, default: float) -> float: - """Read a timeout from the environment, falling back on anything unusable. - - A workflow that sets this from an unset matrix key passes an empty string - rather than leaving the variable out, and bare float("") raises. Treat - empty, non-numeric and non-positive values as "not configured" so a typo - cannot turn a timeout into a crash -- or into zero. - """ - raw = os.environ.get(name, "") - try: - value = float(raw) - except ValueError: - return default - return value if value > 0 else default - - ISO = sys.argv[1] if len(sys.argv) > 1 else "build/pythonos.iso" PORT = int(os.environ.get("PYTHONOS_GUI_HOST_PORT", "5559")) -BOOT_TIMEOUT = _env_seconds("PYTHONOS_GUI_BOOT_TIMEOUT", 30.0) -COMMAND_TIMEOUT = _env_seconds("PYTHONOS_GUI_COMMAND_TIMEOUT", 15.0) +BOOT_TIMEOUT = env_seconds("PYTHONOS_GUI_BOOT_TIMEOUT", 30.0) +COMMAND_TIMEOUT = env_seconds("PYTHONOS_GUI_COMMAND_TIMEOUT", 15.0) SERIAL_LOG = "/tmp/pythonos-gui-smoke.log" MONITOR_SOCK = "/tmp/pythonos-gui-smoke.mon.sock" diff --git a/tests/qmp_helper.py b/tests/qmp_helper.py index 9cddd89..49d49ae 100644 --- a/tests/qmp_helper.py +++ b/tests/qmp_helper.py @@ -20,6 +20,22 @@ import time +def env_seconds(name: str, default: float) -> float: + """Read a timeout from the environment, falling back on anything unusable. + + A workflow that sets one of these from a matrix key defined for only some + entries passes an empty string to the rest rather than leaving the variable + out, and bare float("") raises. Empty, non-numeric and non-positive values + all mean "not configured" here, so a typo cannot turn a timeout into a + crash -- or into zero. + """ + try: + value = float(os.environ.get(name, "")) + except ValueError: + return default + return value if value > 0 else default + + class QemuMonitor: """Connection to a QEMU HMP monitor exposed at a Unix socket.