feat(hal): drive Lian Li wireless fans and their LCD receivers #1086
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI/CD | |
| on: | |
| push: | |
| branches: [main] | |
| tags: | |
| - "v*.*.*" | |
| pull_request: | |
| workflow_dispatch: | |
| inputs: | |
| release_artifacts: | |
| description: "Build release artifacts without publishing a GitHub Release" | |
| required: false | |
| type: choice | |
| default: none | |
| options: | |
| - none | |
| - smoke | |
| - full | |
| release_version: | |
| description: "Dry-run semver for artifact names (blank = Cargo version + -ci.0)" | |
| required: false | |
| deploy_docs: | |
| description: "Deploy the current main docs site to GitHub Pages" | |
| required: false | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| # Tag runs publish, and a release is only ever cut from a commit main has | |
| # already proven green, so neither may be superseded. Cancelling main means | |
| # the next merge silently discards an hour of Windows and Servo work and | |
| # leaves the commit a release would ship with no verdict at all. Branch and | |
| # pull request runs stay cancellable, which is where the savings actually are. | |
| cancel-in-progress: >- | |
| ${{ !startsWith(github.ref, 'refs/tags/') && | |
| github.ref != 'refs/heads/main' }} | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| RUST_TOOLCHAIN: "1.95.0" | |
| XCODE_VERSION: "26.5" | |
| MACOSX_DEPLOYMENT_TARGET: "15.2" | |
| APT_STEP_TIMEOUT: 35m | |
| APT_HTTP_TIMEOUT: "20" | |
| APT_RETRIES: "5" | |
| # System deps shared across Linux jobs | |
| LINUX_DEPS: >- | |
| libudev-dev libdbus-1-dev libasound2-dev libpulse0 | |
| libpipewire-0.3-dev | |
| ccache clang cmake jq nasm pkg-config lld | |
| libxcb1-dev libxcb-randr0-dev libxcb-shm0-dev libxcb-xfixes0-dev | |
| libxdo-dev | |
| libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev | |
| librsvg2-dev libfontconfig1-dev libegl1 libssl-dev | |
| LINUX_RUST_DEPS: >- | |
| libudev-dev libdbus-1-dev libasound2-dev libpulse0 | |
| libpipewire-0.3-dev | |
| ccache clang cmake jq nasm pkg-config lld ripgrep | |
| libxcb1-dev libxcb-randr0-dev libxcb-shm0-dev libxcb-xfixes0-dev | |
| libxdo-dev libfontconfig1-dev libegl1 libssl-dev | |
| # Servo and native app checks run separately so their feature/system deps do | |
| # not force unrelated crates onto the slow path. | |
| RUST_SHARED_WORKSPACE_ARGS: >- | |
| --workspace | |
| --exclude hypercolor-daemon | |
| --exclude hypercolor-app | |
| # Windows scope additionally drops the CLI, which depends on the daemon and | |
| # so drags in Servo and mozangle. Those need a Visual Studio developer | |
| # environment a bare runner does not have, and building them would tell us | |
| # nothing about the Windows-only code this job exists to gate. | |
| RUST_WINDOWS_WORKSPACE_ARGS: >- | |
| --workspace | |
| --exclude hypercolor-daemon | |
| --exclude hypercolor-app | |
| --exclude hypercolor-cli | |
| HYPERCOLOR_CACHE_DIR: ${{ github.workspace }}/.cache/hypercolor | |
| # Tests resolve bundled effects next to the running executable unless this is | |
| # set, and CI invokes cargo directly instead of through the justfile that | |
| # normally exports it. Every job that stages generated effects puts them here. | |
| HYPERCOLOR_EFFECTS_DIR: ${{ github.workspace }}/effects/hypercolor | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/hypercolor/target | |
| MOZBUILD_STATE_PATH: ${{ github.workspace }}/.cache/hypercolor/mozbuild | |
| CARGO_INCREMENTAL: "0" | |
| permissions: | |
| contents: read | |
| jobs: | |
| # ── Change Detection ─────────────────────────────────────────── | |
| changes: | |
| name: Detect Changes | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| # True for anything that is not a pull request, which is deliberately | |
| # broad: it gates the expensive Servo test and doc-test steps so pull | |
| # requests get the light pass and main and tags get the heavy one. It is | |
| # NOT a stand-in for "release build", and it must not gate whole jobs — | |
| # the per-domain outputs below already force true for tags and non-smoke | |
| # dispatch, so they cover release on their own. | |
| full: ${{ startsWith(github.ref, 'refs/tags/') || (github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.release_artifacts != 'smoke')) }} | |
| rust: ${{ (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke') && 'false' || (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts != 'smoke')) && 'true' || steps.filter.outputs.rust }} | |
| python: ${{ (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke') && 'false' || (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts != 'smoke')) && 'true' || steps.filter.outputs.python }} | |
| python_generated: ${{ (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke') && 'false' || (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts != 'smoke')) && 'true' || steps.filter.outputs.python_generated }} | |
| sdk: ${{ (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke') && 'false' || (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts != 'smoke')) && 'true' || steps.filter.outputs.sdk }} | |
| ui: ${{ (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke') && 'false' || (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts != 'smoke')) && 'true' || steps.filter.outputs.ui }} | |
| compat: ${{ (github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke') && 'false' || (startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.release_artifacts != 'smoke')) && 'true' || steps.filter.outputs.compat }} | |
| docs: ${{ steps.filter.outputs.docs }} | |
| workflow: ${{ steps.filter.outputs.workflow }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dorny/paths-filter@v4.0.1 | |
| id: filter | |
| with: | |
| filters: | | |
| rust: | |
| - 'crates/**' | |
| - 'e2e/**' | |
| - 'protocol/**' | |
| - 'Cargo.toml' | |
| - 'Cargo.lock' | |
| - 'rust-toolchain.toml' | |
| - 'deny.toml' | |
| - 'scripts/cargo-cache-build.*' | |
| - 'scripts/cargo-cache-cargo-shim.sh' | |
| - 'scripts/cargo-target-gc.sh' | |
| - 'scripts/check-macos-gpu-only.sh' | |
| - 'scripts/tests/cargo-cache-build-tests.sh' | |
| - 'scripts/tests/cargo-target-gc-tests.sh' | |
| - 'scripts/sign-macos-artifacts.sh' | |
| - 'scripts/macos-signing-keychain.c' | |
| - 'scripts/build-mac-installer.sh' | |
| - 'scripts/tests/macos-signing-secret-transport-tests.sh' | |
| - 'packaging/systemd/user/hypercolor-cargo-target-gc.*' | |
| - 'justfile' | |
| - '.github/actions/rust-build-cache/**' | |
| - '.github/workflows/ci.yml' | |
| # Decides whether Windows sees LF or CRLF, which changes what | |
| # include_str! embeds and what those tests can match. | |
| - '.gitattributes' | |
| # Embedded with include_str! and asserted on by the HAL tests. | |
| - 'data/drivers/**' | |
| # Embedded with include_str! by hypercolor-color's vector tests. | |
| # Without this the Rust half of the cross-language color fence | |
| # skips a pull request that only edits the shared table. | |
| - 'sdk/shared/color-vectors.json' | |
| python: | |
| - 'python/**' | |
| - 'protocol/**' | |
| - 'justfile' | |
| - '.github/workflows/ci.yml' | |
| python_generated: | |
| - 'python/scripts/generate_openapi_client.py' | |
| - 'python/scripts/generate_ws_protocol.py' | |
| - 'python/src/hypercolor/_generated/**' | |
| - 'python/src/hypercolor/ws_protocol.py' | |
| - 'python/pyproject.toml' | |
| - 'python/uv.lock' | |
| - 'protocol/**' | |
| - '.github/workflows/ci.yml' | |
| sdk: | |
| - 'sdk/**' | |
| - 'protocol/**' | |
| - 'crates/hypercolor-core/src/effect/lightscript/frame_payload_adapter.js' | |
| - 'justfile' | |
| - '.github/actions/bun-cache/**' | |
| - '.github/workflows/ci.yml' | |
| ui: | |
| - 'crates/hypercolor-ui/**' | |
| - 'crates/hypercolor-leptos-ext/**' | |
| - 'protocol/**' | |
| - 'justfile' | |
| - '.github/actions/bun-cache/**' | |
| - '.github/actions/rust-build-cache/**' | |
| - '.github/workflows/ci.yml' | |
| compat: | |
| - 'data/drivers/vendors/**' | |
| - 'data/compat/**' | |
| - 'docs/content/hardware/compatibility.md' | |
| - 'scripts/gen-compat.ts' | |
| docs: | |
| - 'docs/**' | |
| - '.agents/**/*.md' | |
| - 'AGENTS.md' | |
| - 'crates/*/README.md' | |
| - 'data/drivers/vendors/**' | |
| - 'data/compat/**' | |
| - 'scripts/check-retired-api-docs.sh' | |
| - 'scripts/gen-compat.ts' | |
| workflow: | |
| - '.github/workflows/**' | |
| - '.github/actions/**' | |
| # ── Workflow Lint ───────────────────────────────────────────── | |
| workflow-lint: | |
| name: Workflow Lint | |
| needs: changes | |
| if: needs.changes.outputs.workflow == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Actionlint | |
| run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 .github/workflows/*.yml | |
| # ── Rust Check (fmt + clippy) ────────────────────────────────── | |
| rust-check-shared: | |
| name: Rust Check / Shared | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| env: | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/hypercolor/target/rust-check-shared | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install system dependencies | |
| run: | | |
| sudo timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| update | |
| sudo env DEBIAN_FRONTEND=noninteractive timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| install -y --no-install-recommends ${{ env.LINUX_RUST_DEPS }} | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| components: clippy | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| shared-key: rust-check-shared | |
| workspaces: . -> .cache/hypercolor/target/rust-check-shared | |
| cache-on-failure: "false" | |
| - name: Check formatting | |
| run: | | |
| cargo fmt --all --check | |
| cargo fmt --manifest-path crates/hypercolor-ui/Cargo.toml --check | |
| - name: Check macOS GPU-only architecture | |
| run: ./scripts/check-macos-gpu-only.sh | |
| - name: Test local build fabric | |
| run: | | |
| ./scripts/tests/cargo-cache-build-tests.sh | |
| ./scripts/tests/cargo-target-gc-tests.sh | |
| - name: Clippy shared crates | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo clippy --locked ${{ env.RUST_SHARED_WORKSPACE_ARGS }} --all-targets | |
| -- -D warnings | |
| rust-check-macos: | |
| name: Rust macOS / ${{ matrix.label }} | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 120 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - label: Apple Silicon | |
| os: macos-26 | |
| expected-arch: arm64 | |
| - label: Intel | |
| os: macos-26-intel | |
| expected-arch: x86_64 | |
| env: | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/hypercolor/target/rust-check-macos | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Qualify macOS runner and SDK | |
| run: | | |
| set -euo pipefail | |
| sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" | |
| xcodebuild -version | |
| sdk_version="$(xcrun --show-sdk-version)" | |
| printf 'macOS SDK: %s\n' "${sdk_version}" | |
| test "$(uname -m)" = "${{ matrix.expected-arch }}" | |
| test "${sdk_version%%.*}" = "26" | |
| - name: Verify macOS signing secret transport | |
| run: ./scripts/tests/macos-signing-secret-transport-tests.sh | |
| - name: Install NASM | |
| if: matrix.expected-arch == 'x86_64' | |
| run: brew install nasm | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| components: clippy | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| shared-key: rust-check-macos-${{ matrix.expected-arch }} | |
| workspaces: . -> .cache/hypercolor/target/rust-check-macos | |
| cache-on-failure: "false" | |
| - name: Install nextest | |
| uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 | |
| with: | |
| tool: cargo-nextest | |
| - name: Qualify Intel Metal fixture | |
| if: matrix.expected-arch == 'x86_64' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo nextest run --locked -p hypercolor-macos-gpu-interop | |
| --features screen-capture --test screen_capture_bridge_tests | |
| -E 'test(intel_runner_qualification_requires_native_device_and_both_import_candidates)' | |
| - name: Seed native app frontend fixture | |
| run: | | |
| mkdir -p crates/hypercolor-ui/dist | |
| printf '<!doctype html><title>Hypercolor test fixture</title>' \ | |
| > crates/hypercolor-ui/dist/index.html | |
| - name: Check macOS workspace | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo check --workspace --locked | |
| - name: Clippy macOS interop | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo clippy --locked -p hypercolor-macos-gpu-interop --features screen-capture | |
| --all-targets | |
| -- -D warnings | |
| - name: Clippy macOS capture fixtures | |
| run: | | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo clippy --locked \ | |
| -p hypercolor-macos-capture --features capture-fixtures --all-targets \ | |
| -- -D warnings | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo clippy --locked \ | |
| -p hypercolor-core --features macos-capture-fixtures \ | |
| --lib --test macos_screen_capture_tests \ | |
| -- -D warnings | |
| - name: Clippy macOS host input and ownership | |
| run: | | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo clippy --locked \ | |
| -p hypercolor-macos-input --all-targets \ | |
| -- -D warnings | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo clippy --locked \ | |
| -p hypercolor-macos-owner --all-targets \ | |
| -- -D warnings | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo clippy --locked \ | |
| -p hypercolor-daemon --no-default-features \ | |
| --bin hypercolor-daemon --test macos_owner_tests \ | |
| -- -D warnings | |
| - name: Run macOS interop fixtures | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo nextest run --locked -p hypercolor-macos-gpu-interop | |
| --features screen-capture | |
| - name: Run macOS capture fixtures | |
| run: | | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo nextest run --locked \ | |
| -p hypercolor-macos-capture --features capture-fixtures | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo nextest run --locked --no-tests=fail \ | |
| -p hypercolor-core --features macos-capture-fixtures \ | |
| --lib --test macos_screen_capture_tests \ | |
| -E 'test(screen::macos) | binary(macos_screen_capture_tests)' | |
| - name: Run macOS host input and ownership fixtures | |
| run: | | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo nextest run --locked \ | |
| -p hypercolor-macos-input \ | |
| --test input_contract_tests \ | |
| --test process_identity_tests | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo nextest run --locked \ | |
| -p hypercolor-macos-owner \ | |
| --test coordinator_tests | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo nextest run --locked \ | |
| -p hypercolor-core --features macos-native-fixtures \ | |
| --test macos_host_input_tests | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo nextest run --locked \ | |
| -p hypercolor-daemon --no-default-features \ | |
| --test macos_owner_tests | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo nextest run --locked \ | |
| -p hypercolor-daemon --no-default-features \ | |
| --lib \ | |
| -E 'test(/(launchd_managed_contenders_exit_zero_without_respawn|held_guard_applies_topology_policy_without_an_owner_record|malformed_diagnostics_never_override_held_guard_policy)/)' | |
| - name: Run macOS status API fixtures | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo nextest run --locked | |
| -p hypercolor-daemon --no-default-features --features wgpu | |
| -E 'test(/api::system::tests::(input_source_status|macos_)/)' | |
| - name: Build deployment and Sequoia availability fixtures | |
| run: | | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo build --locked -p hypercolor-cli --bin hypercolor | |
| ./scripts/cargo-cache-build.sh \ | |
| cargo build --locked -p hypercolor-daemon --no-default-features \ | |
| --features wgpu,screen-capture --bin hypercolor-daemon | |
| - name: Verify deployment target | |
| run: | | |
| ./scripts/verify-macos-deployment-target.sh \ | |
| "${CARGO_TARGET_DIR}/debug/hypercolor" \ | |
| "${CARGO_TARGET_DIR}/debug/hypercolor-daemon" | |
| - name: Reject unguarded Tahoe symbols in the Sequoia artifact | |
| run: | | |
| set -euo pipefail | |
| artifact="${CARGO_TARGET_DIR}/debug/hypercolor-daemon" | |
| tahoe_symbols='SCScreenshot(Configuration|Manager)|CG(Context(Get|Set)ContentToneMappingInfo|ImageGetContentAverageLightLevel)|kCG(PreferredDynamicRange|DynamicRange(Standard|Constrained|High)|ContentAverageLightLevel)' | |
| if xcrun nm -u "${artifact}" | grep -E "${tahoe_symbols}"; then | |
| echo "unguarded Tahoe-only symbol found in ${artifact}" >&2 | |
| exit 1 | |
| fi | |
| echo "Sequoia availability scan passed: Tahoe-only APIs are runtime-resolved" | |
| # ── Generated Effects Artifact ──────────────────────────────── | |
| generated-effects: | |
| name: Generated Effects | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/bun-cache | |
| with: | |
| key: effects-${{ hashFiles('sdk/bun.lock') }} | |
| - name: Install SDK dependencies | |
| working-directory: sdk | |
| run: bun install --frozen-lockfile | |
| - name: Build generated effects | |
| working-directory: sdk | |
| run: bun run build:effects | |
| - name: Upload generated effects | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: generated-effects | |
| path: effects/hypercolor | |
| if-no-files-found: error | |
| # ── Rust Test ────────────────────────────────────────────────── | |
| rust-test: | |
| name: Rust Test / ${{ matrix.label }} | |
| needs: [changes, generated-effects] | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - suite: shared | |
| label: Shared | |
| shared_key: rust-test-shared | |
| target_dir: .cache/hypercolor/target/rust-test-shared | |
| - suite: native | |
| label: Native App | |
| shared_key: rust-test-native | |
| target_dir: .cache/hypercolor/target/rust-test-native | |
| - suite: daemon | |
| label: Daemon | |
| shared_key: rust-test-daemon | |
| target_dir: .cache/hypercolor/target/rust-test-daemon | |
| env: | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/${{ matrix.target_dir }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install system dependencies | |
| run: | | |
| sudo timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| update | |
| sudo env DEBIAN_FRONTEND=noninteractive timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| install -y --no-install-recommends ${{ matrix.suite == 'native' && env.LINUX_DEPS || env.LINUX_RUST_DEPS }} | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| components: clippy | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| shared-key: ${{ matrix.shared_key }} | |
| workspaces: . -> ${{ matrix.target_dir }} | |
| cache-on-failure: "false" | |
| - name: Download generated effects | |
| if: matrix.suite == 'shared' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: generated-effects | |
| path: effects/hypercolor | |
| - name: Install nextest | |
| uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 | |
| with: | |
| tool: cargo-nextest | |
| - name: Seed native app frontend fixture | |
| if: matrix.suite == 'native' | |
| run: | | |
| mkdir -p crates/hypercolor-ui/dist | |
| printf '<!doctype html><title>Hypercolor test fixture</title>' \ | |
| > crates/hypercolor-ui/dist/index.html | |
| - name: Run shared crate tests | |
| if: matrix.suite == 'shared' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo nextest run --locked ${{ env.RUST_SHARED_WORKSPACE_ARGS }} | |
| - name: Run allocation contracts serially | |
| if: matrix.suite == 'shared' | |
| run: | | |
| ./scripts/cargo-cache-build.sh cargo test --locked -p hypercolor-core --no-default-features --features allocation-contract-tests --test alloc_contract_tests --test media_input_allocation_tests --test screen_cpu_fanout_allocation_tests --test spatial_area_reuse_tests -- --test-threads=1 | |
| ./scripts/cargo-cache-build.sh cargo test --locked -p hypercolor-windows-input --test alloc_contract_tests -- --test-threads=1 | |
| - name: Clippy allocation contract | |
| if: matrix.suite == 'shared' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh cargo clippy --locked -p hypercolor-core | |
| --no-default-features --features allocation-contract-tests | |
| --test alloc_contract_tests --test media_input_allocation_tests | |
| --test screen_cpu_fanout_allocation_tests | |
| --test spatial_area_reuse_tests | |
| -- -D warnings | |
| - name: Run shared crate doc tests | |
| if: matrix.suite == 'shared' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo test --doc --locked ${{ env.RUST_SHARED_WORKSPACE_ARGS }} | |
| - name: Clippy native app crates | |
| if: matrix.suite == 'native' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo clippy --locked -p hypercolor-app --all-targets | |
| -- -D warnings | |
| - name: Run native app tests | |
| if: matrix.suite == 'native' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo nextest run --locked -p hypercolor-app | |
| - name: Run native app doc tests | |
| if: matrix.suite == 'native' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo test --doc --locked -p hypercolor-app | |
| - name: Seed effect cover fixture | |
| if: matrix.suite == 'daemon' | |
| run: | | |
| mkdir -p effects/screenshots/curated/rainbow | |
| printf 'RIFFtest-webp-fixture' > effects/screenshots/curated/rainbow/default.webp | |
| - name: Run daemon tests | |
| if: matrix.suite == 'daemon' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo nextest run --locked -p hypercolor-daemon --no-default-features | |
| --features builtin-drivers,persistence-test-hooks --test-threads=1 | |
| - name: Run daemon doc tests | |
| if: matrix.suite == 'daemon' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo test --doc --locked -p hypercolor-daemon --no-default-features | |
| --features builtin-drivers | |
| # Bare core Servo and daemon-default Servo stay as separate commands so | |
| # feature unification cannot hide CPU fallback coverage. They share one job, | |
| # target, and cache because the daemon graph contains the core graph. | |
| rust-test-servo: | |
| name: Rust Servo + E2E Build | |
| needs: [changes, generated-effects] | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| env: | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/hypercolor/target/servo | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install system dependencies | |
| run: | | |
| sudo timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| update | |
| sudo env DEBIAN_FRONTEND=noninteractive timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| install -y --no-install-recommends ${{ env.LINUX_RUST_DEPS }} | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| components: clippy | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| shared-key: servo | |
| workspaces: . -> .cache/hypercolor/target/servo | |
| cache-workspace-crates: "true" | |
| cache-directories: | | |
| .cache/hypercolor/mozbuild | |
| .cache/hypercolor/toolchain | |
| .cache/hypercolor/ccache | |
| cache-on-failure: "false" | |
| - name: Download generated effects | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: generated-effects | |
| path: effects/hypercolor | |
| - name: Build Servo e2e binaries | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo build --locked -p hypercolor-daemon -p hypercolor-cli | |
| - name: Clippy core with Servo | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo clippy --locked -p hypercolor-core --features servo --lib --tests | |
| -- -D warnings | |
| - name: Clippy daemon with Servo | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo clippy --locked -p hypercolor-daemon --lib --bin hypercolor-daemon --tests | |
| -- -D warnings | |
| - name: Run core Servo tests | |
| if: needs.changes.outputs.full == 'true' | |
| run: | | |
| ./scripts/cargo-cache-build.sh cargo test --locked -p hypercolor-core --features servo --test effect_factory_tests | |
| ./scripts/cargo-cache-build.sh cargo test --locked -p hypercolor-core --features servo --test builtin_effect_tests | |
| - name: Run Servo HTML loader registration tests | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo test --locked -p hypercolor-core --features servo | |
| --test html_loader_tests | |
| - name: Run core Servo doc tests | |
| if: needs.changes.outputs.full == 'true' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo test --doc --locked -p hypercolor-core --features servo | |
| - name: Run daemon Servo tests | |
| if: needs.changes.outputs.full == 'true' | |
| run: | | |
| ./scripts/cargo-cache-build.sh cargo test --locked -p hypercolor-daemon --lib -- --test-threads=1 | |
| ./scripts/cargo-cache-build.sh cargo test --locked -p hypercolor-daemon --bin hypercolor-daemon -- --test-threads=1 | |
| - name: Run daemon Servo doc tests | |
| if: needs.changes.outputs.full == 'true' | |
| run: >- | |
| ./scripts/cargo-cache-build.sh | |
| cargo test --doc --locked -p hypercolor-daemon | |
| - name: Stage Servo e2e binaries | |
| run: | | |
| mkdir -p e2e-binaries | |
| install -m755 "${CARGO_TARGET_DIR}/debug/hypercolor-daemon" \ | |
| e2e-binaries/hypercolor-daemon | |
| install -m755 "${CARGO_TARGET_DIR}/debug/hypercolor" \ | |
| e2e-binaries/hypercolor | |
| - name: Upload Servo e2e binaries | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-binaries-servo | |
| path: e2e-binaries | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # ── Rust Windows ─────────────────────────────────────────────── | |
| # Every #[cfg(target_os = "windows")] assertion in the shared crates is only | |
| # a gate because this job exists. Windows previously ran in build-native-app | |
| # alone, which is tag-gated, so Windows-only code paths reached main without | |
| # ever having been compiled by CI. | |
| rust-windows: | |
| name: Rust Windows | |
| needs: [changes, generated-effects] | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: windows-latest | |
| # Sized for a cold run. The steps below sweep five feature sets, so | |
| # almost all of the wall time is rustc, not tests, and a cache miss puts | |
| # the whole sweep well past an hour. | |
| timeout-minutes: 90 | |
| env: | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/hypercolor/target/rust-windows | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| components: clippy | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| shared-key: rust-windows | |
| workspaces: . -> .cache/hypercolor/target/rust-windows | |
| # Saving only on success is self-perpetuating here: no cache makes | |
| # the job slow enough to fail, and failing is what withholds the | |
| # cache. Seeding from failed runs is what breaks back out. | |
| cache-on-failure: "true" | |
| - name: Download generated effects | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: generated-effects | |
| path: effects/hypercolor | |
| - name: Install nextest | |
| uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 | |
| with: | |
| tool: cargo-nextest | |
| - name: Clippy shared crates | |
| run: >- | |
| cargo clippy --locked ${{ env.RUST_WINDOWS_WORKSPACE_ARGS }} --all-targets | |
| -- -D warnings | |
| - name: Clippy Windows capture fixtures | |
| run: >- | |
| cargo clippy --locked -p hypercolor-core | |
| --features windows-capture-fixtures --all-targets -- -D warnings | |
| - name: Run shared crate tests | |
| run: cargo nextest run --locked ${{ env.RUST_WINDOWS_WORKSPACE_ARGS }} | |
| - name: Run Windows capture and host input fixtures | |
| run: >- | |
| cargo nextest run --locked --no-tests=fail -p hypercolor-core | |
| --features windows-capture-fixtures | |
| --lib --test windows_host_input_fixture_tests | |
| --test windows_capture_fixture_tests | |
| -E 'test(screen::windows) | binary(windows_host_input_fixture_tests) | |
| | binary(windows_capture_fixture_tests)' | |
| - name: Run allocation contracts serially | |
| shell: pwsh | |
| run: | | |
| cargo test --locked -p hypercolor-core --no-default-features --features allocation-contract-tests --test alloc_contract_tests --test media_input_allocation_tests --test screen_cpu_fanout_allocation_tests --test spatial_area_reuse_tests -- --test-threads=1 | |
| if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } | |
| cargo test --locked -p hypercolor-windows-input --test alloc_contract_tests -- --test-threads=1 | |
| if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } | |
| - name: Clippy allocation contract | |
| run: >- | |
| cargo clippy --locked -p hypercolor-core | |
| --no-default-features --features allocation-contract-tests | |
| --test alloc_contract_tests --test media_input_allocation_tests | |
| --test screen_cpu_fanout_allocation_tests | |
| --test spatial_area_reuse_tests | |
| -- -D warnings | |
| - name: Seed effect cover fixture | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force effects/screenshots/curated/rainbow | Out-Null | |
| [IO.File]::WriteAllBytes( | |
| "effects/screenshots/curated/rainbow/default.webp", | |
| [Text.Encoding]::ASCII.GetBytes("RIFFtest-webp-fixture") | |
| ) | |
| - name: Run daemon input and capture integration tests | |
| run: >- | |
| cargo nextest run --locked -p hypercolor-daemon --no-default-features | |
| --features builtin-drivers,persistence-test-hooks --test-threads=1 | |
| # ── Cargo Deny ───────────────────────────────────────────────── | |
| rust-deny: | |
| name: Cargo Deny | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: EmbarkStudios/cargo-deny-action@v2 | |
| with: | |
| arguments: "" | |
| # ── Compatibility Matrix Drift ───────────────────────────────── | |
| # Fails if data/drivers/vendors/*.toml was edited without regenerating the | |
| # compatibility matrix (README compat block, Zola page, JSON, snippet). | |
| # Zero external deps; the generator uses only Bun's stdlib. | |
| compat: | |
| name: Compatibility Matrix | |
| needs: changes | |
| # `compat` is already forced true for tags and non-smoke dispatch, so it | |
| # covers release builds on its own. Adding `full` here only meant "every | |
| # push to main", which rebuilt this on docs-only merges. | |
| if: needs.changes.outputs.compat == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.4.0 | |
| - name: Verify compatibility matrix is current | |
| run: bun scripts/gen-compat.ts --check | |
| # ── Python Client ──────────────────────────────────────────────── | |
| python: | |
| name: Python Client | |
| needs: changes | |
| if: >- | |
| startsWith(github.ref, 'refs/tags/') || | |
| needs.changes.outputs.python == 'true' | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: python | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: python/uv.lock | |
| - uses: actions/setup-python@v6 | |
| with: | |
| python-version-file: python/.python-version | |
| - name: Install dependencies | |
| run: uv sync --frozen | |
| - name: Ruff check | |
| run: uv run ruff check . | |
| - name: Ruff format check | |
| run: uv run ruff format --check . | |
| - name: Type check | |
| run: uv run ty check | |
| - name: Verify WebSocket protocol constants | |
| run: uv run python scripts/generate_ws_protocol.py --check | |
| - name: Run tests | |
| run: uv run pytest | |
| # ── Python Generated Client Drift ────────────────────────────── | |
| python-generated: | |
| name: Python Generated Client | |
| needs: [changes, rust-check-shared] | |
| if: >- | |
| always() && | |
| (startsWith(github.ref, 'refs/tags/') || | |
| needs.changes.outputs.python_generated == 'true' || | |
| needs.changes.outputs.rust == 'true') && | |
| (needs.rust-check-shared.result == 'success' || | |
| needs.rust-check-shared.result == 'skipped') | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: python | |
| env: | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/hypercolor/target/rust-test-daemon | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install system dependencies | |
| run: | | |
| sudo timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| update | |
| sudo env DEBIAN_FRONTEND=noninteractive timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| install -y --no-install-recommends ${{ env.LINUX_RUST_DEPS }} | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| with: | |
| enable-cache: true | |
| cache-dependency-glob: python/uv.lock | |
| - uses: actions/setup-python@v6 | |
| with: | |
| python-version-file: python/.python-version | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| shared-key: rust-test-daemon | |
| workspaces: . -> .cache/hypercolor/target/rust-test-daemon | |
| save-if: "false" | |
| - name: Install dependencies | |
| run: uv sync --frozen | |
| - name: Verify generated client is current | |
| run: uv run python scripts/generate_openapi_client.py --check | |
| - name: Verify WebSocket protocol manifest is current | |
| working-directory: . | |
| run: cargo run -q -p hypercolor-daemon --bin hypercolor-ws-manifest -- --check | |
| - name: Verify generated WebSocket constants are current | |
| run: uv run python scripts/generate_ws_protocol.py --check | |
| # ── Python Package ─────────────────────────────────────────────── | |
| python-build: | |
| name: Python Package | |
| needs: [python, python-generated] | |
| if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: python | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - uses: actions/setup-python@v6 | |
| with: | |
| python-version-file: python/.python-version | |
| - name: Verify package version matches the tag | |
| run: | | |
| # The atomic release commit stamps pyproject.toml; a mismatch | |
| # means this tag was not cut through release.yml. | |
| version="${GITHUB_REF_NAME#v}" | |
| grep -Fx "version = \"${version}\"" pyproject.toml || { | |
| echo "pyproject.toml version does not match tag ${GITHUB_REF_NAME}" >&2 | |
| grep '^version = ' pyproject.toml >&2 | |
| exit 1 | |
| } | |
| - name: Build package | |
| run: uv build | |
| - name: Upload Python distributions | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: python-dist | |
| path: python/dist/* | |
| if-no-files-found: error | |
| # ── SDK (TypeScript / Bun) ───────────────────────────────────── | |
| sdk: | |
| name: SDK | |
| needs: changes | |
| if: needs.changes.outputs.sdk == 'true' | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: sdk | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/bun-cache | |
| with: | |
| key: sdk-${{ hashFiles('sdk/bun.lock') }} | |
| - run: bun install --frozen-lockfile | |
| - run: bun test | |
| - run: bun run check | |
| - run: bun run typecheck | |
| - run: bun run build | |
| - run: bun run build:effects | |
| # ── UI (Leptos / Trunk) ──────────────────────────────────────── | |
| ui: | |
| name: UI | |
| needs: changes | |
| if: >- | |
| needs.changes.outputs.rust == 'true' || | |
| needs.changes.outputs.ui == 'true' | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: crates/hypercolor-ui | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/bun-cache | |
| with: | |
| key: ui-${{ hashFiles('crates/hypercolor-ui/bun.lock') }} | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| targets: wasm32-unknown-unknown | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| key: ui | |
| - run: bun install --frozen-lockfile | |
| - run: cargo test | |
| - name: Install Trunk | |
| uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 | |
| with: | |
| tool: trunk@0.21.14 | |
| - run: env -u NO_COLOR trunk build --release | |
| - name: Upload e2e UI build | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-ui-dist | |
| path: crates/hypercolor-ui/dist | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # ── End-to-End (Playwright) ────────────────────────────────── | |
| e2e-build-cpu: | |
| name: E2E Build / CPU Smoke | |
| needs: changes | |
| if: needs.changes.outputs.rust == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 75 | |
| env: | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/.cache/hypercolor/target/rust-test-daemon | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install system dependencies | |
| run: | | |
| sudo timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| update | |
| sudo env DEBIAN_FRONTEND=noninteractive timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| install -y --no-install-recommends ${{ env.LINUX_RUST_DEPS }} | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| shared-key: rust-test-daemon | |
| workspaces: . -> .cache/hypercolor/target/rust-test-daemon | |
| save-if: "false" | |
| - name: Build CPU e2e binaries | |
| run: | | |
| ./scripts/cargo-cache-build.sh cargo build --locked -p hypercolor-daemon --no-default-features --features builtin-drivers | |
| ./scripts/cargo-cache-build.sh cargo build --locked -p hypercolor-cli | |
| - name: Stage CPU e2e binaries | |
| run: | | |
| mkdir -p e2e-binaries | |
| install -m755 "${CARGO_TARGET_DIR}/debug/hypercolor-daemon" \ | |
| e2e-binaries/hypercolor-daemon | |
| install -m755 "${CARGO_TARGET_DIR}/debug/hypercolor" \ | |
| e2e-binaries/hypercolor | |
| - name: Upload CPU e2e binaries | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-binaries-cpu | |
| path: e2e-binaries | |
| if-no-files-found: error | |
| retention-days: 1 | |
| e2e-assemble: | |
| name: E2E Assemble / ${{ matrix.label }} | |
| needs: [rust-test-servo, e2e-build-cpu, generated-effects, ui] | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - suite: servo | |
| label: Servo | |
| - suite: cpu | |
| label: CPU Smoke | |
| steps: | |
| - name: Download e2e binaries | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: e2e-binaries-${{ matrix.suite }} | |
| path: e2e-stack/bin | |
| - name: Download generated effects | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: generated-effects | |
| path: e2e-stack/effects | |
| - name: Download UI build | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: e2e-ui-dist | |
| path: e2e-stack/ui | |
| - name: Upload e2e stack | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-stack-${{ matrix.suite }} | |
| path: e2e-stack | |
| if-no-files-found: error | |
| retention-days: 1 | |
| e2e: | |
| name: E2E / ${{ matrix.label }} | |
| needs: [rust-check-shared, e2e-assemble] | |
| if: >- | |
| always() && | |
| (needs.rust-check-shared.result == 'success' || | |
| needs.rust-check-shared.result == 'skipped') && | |
| needs.e2e-assemble.result == 'success' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - suite: servo | |
| label: Servo | |
| - suite: cpu | |
| label: CPU Smoke | |
| env: | |
| HYPERCOLOR_E2E_KEEP_RUN_DIR: "1" | |
| HYPERCOLOR_E2E_DAEMON_BIN: ${{ github.workspace }}/e2e-stack/bin/hypercolor-daemon | |
| HYPERCOLOR_E2E_CLI_BIN: ${{ github.workspace }}/e2e-stack/bin/hypercolor | |
| HYPERCOLOR_E2E_UI_DIST_DIR: ${{ github.workspace }}/crates/hypercolor-ui/dist | |
| HYPERCOLOR_E2E_STACK: ${{ matrix.suite }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Download e2e stack | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: e2e-stack-${{ matrix.suite }} | |
| path: e2e-stack | |
| - name: Install system dependencies | |
| run: | | |
| sudo timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| update | |
| sudo env DEBIAN_FRONTEND=noninteractive timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| install -y --no-install-recommends ${{ env.LINUX_RUST_DEPS }} | |
| - uses: actions/setup-node@v6 | |
| with: | |
| # Node 24.16+ hangs `playwright install` in extract-zip | |
| # (microsoft/playwright#41000, fixed in Playwright 1.60); stay on | |
| # 22 LTS until the e2e Playwright dependency is bumped past 1.60. | |
| node-version: 22 | |
| cache: npm | |
| cache-dependency-path: e2e/package-lock.json | |
| - name: Restore e2e stack | |
| run: | | |
| chmod +x e2e-stack/bin/hypercolor-daemon e2e-stack/bin/hypercolor | |
| rm -rf crates/hypercolor-ui/dist effects/hypercolor | |
| mkdir -p crates/hypercolor-ui/dist effects/hypercolor | |
| cp -R e2e-stack/ui/. crates/hypercolor-ui/dist/ | |
| cp -R e2e-stack/effects/. effects/hypercolor/ | |
| - name: Install e2e dependencies | |
| working-directory: e2e | |
| run: npm ci | |
| - name: Install Playwright Chromium | |
| working-directory: e2e | |
| run: npx playwright install --with-deps chromium | |
| - name: Run e2e suite | |
| working-directory: e2e | |
| run: npm test | |
| - name: Upload e2e artifacts | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-artifacts-${{ matrix.suite }} | |
| path: | | |
| e2e/playwright-report | |
| e2e/test-results | |
| /tmp/hypercolor-e2e-* | |
| # ── Docs (Zola → GitHub Pages) ───────────────────────────────── | |
| docs-build: | |
| name: Docs Build | |
| needs: changes | |
| if: >- | |
| (github.event_name == 'pull_request' && needs.changes.outputs.docs == 'true') || | |
| (github.ref == 'refs/heads/main' && ( | |
| (github.event_name == 'push' && needs.changes.outputs.docs == 'true') || | |
| (github.event_name == 'workflow_dispatch' && inputs.deploy_docs) | |
| )) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install Zola | |
| run: | | |
| ZOLA_VERSION="0.23.3" | |
| curl -fsSL "https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ | |
| | tar xz -C /usr/local/bin | |
| - name: Build site | |
| working-directory: docs | |
| run: zola build | |
| - name: Check retired API guidance | |
| run: ./scripts/check-retired-api-docs.sh | |
| - name: Upload Pages artifact | |
| if: >- | |
| github.ref == 'refs/heads/main' && | |
| ( | |
| (github.event_name == 'push' && needs.changes.outputs.docs == 'true') || | |
| (github.event_name == 'workflow_dispatch' && inputs.deploy_docs) | |
| ) | |
| uses: actions/upload-pages-artifact@v5 | |
| with: | |
| path: docs/public | |
| docs-deploy: | |
| name: Docs Deploy | |
| needs: [changes, docs-build] | |
| if: >- | |
| github.ref == 'refs/heads/main' && | |
| ( | |
| (github.event_name == 'push' && needs.changes.outputs.docs == 'true') || | |
| (github.event_name == 'workflow_dispatch' && inputs.deploy_docs) | |
| ) | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pages: write | |
| id-token: write | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deploy.outputs.page_url }} | |
| steps: | |
| - name: Deploy to GitHub Pages | |
| id: deploy | |
| uses: actions/deploy-pages@v5 | |
| # ── Web Assets (built once, shared across release platforms) ─── | |
| web-assets: | |
| name: Web Assets | |
| if: >- | |
| startsWith(github.ref, 'refs/tags/') || | |
| (github.event_name == 'workflow_dispatch' && | |
| inputs.release_artifacts != 'none') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/bun-cache | |
| with: | |
| key: web-assets-${{ hashFiles('sdk/bun.lock', 'crates/hypercolor-ui/bun.lock') }} | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| targets: wasm32-unknown-unknown | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| key: web-assets | |
| - name: Install Trunk | |
| uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 | |
| with: | |
| tool: trunk@0.21.14 | |
| - name: Build UI | |
| working-directory: crates/hypercolor-ui | |
| run: | | |
| bun install --frozen-lockfile | |
| env -u NO_COLOR trunk build --release | |
| - name: Build effects | |
| working-directory: sdk | |
| run: | | |
| bun install --frozen-lockfile | |
| bun run build:effects | |
| - name: Stage web assets | |
| run: | | |
| mkdir -p staging/ui staging/effects | |
| cp -R crates/hypercolor-ui/dist/. staging/ui/ | |
| if [ -d effects/hypercolor ]; then | |
| cp -R effects/hypercolor/. staging/effects/ | |
| fi | |
| - name: Upload web assets | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: web-assets | |
| path: staging/ | |
| if-no-files-found: error | |
| # ── Build Native App Installers ─────────────────────────────── | |
| build-native-app: | |
| name: Native App (${{ matrix.target }}) | |
| if: >- | |
| startsWith(github.ref, 'refs/tags/') || | |
| (github.event_name == 'workflow_dispatch' && | |
| inputs.release_artifacts == 'full') | |
| needs: [rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets, python, python-generated] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - target: windows-x64 | |
| os: windows-latest | |
| rust-target: x86_64-pc-windows-msvc | |
| bundles: nsis | |
| artifact-kind: nsis | |
| cask_arch: "" | |
| artifact-path: | | |
| target/release/bundle/nsis/*.exe | |
| crates/hypercolor-app/target/release/bundle/nsis/*.exe | |
| - target: macos-arm64 | |
| os: macos-26 | |
| rust-target: aarch64-apple-darwin | |
| bundles: app | |
| artifact-kind: unsigned-app | |
| cask_arch: arm64 | |
| artifact-path: | | |
| target/aarch64-apple-darwin/release/bundle/macos/*.app | |
| - target: macos-x64 | |
| os: macos-26-intel | |
| rust-target: x86_64-apple-darwin | |
| bundles: app | |
| artifact-kind: unsigned-app | |
| cask_arch: x86_64 | |
| artifact-path: | | |
| target/x86_64-apple-darwin/release/bundle/macos/*.app | |
| runs-on: ${{ matrix.os }} | |
| env: | |
| # Absolute on purpose. Cargo resolves a relative CARGO_TARGET_DIR | |
| # against the current directory, and the Tauri bundle step runs from | |
| # crates/hypercolor-app, so a bare "target" sent that build to its own | |
| # cold directory and recompiled the whole dependency tree the sidecar | |
| # build had just finished compiling. | |
| CARGO_TARGET_DIR: ${{ github.workspace }}/target | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Qualify macOS runner and SDK | |
| if: runner.os == 'macOS' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" | |
| xcodebuild -version | |
| sdk_version="$(xcrun --show-sdk-version)" | |
| printf 'macOS SDK: %s\n' "${sdk_version}" | |
| test "${sdk_version%%.*}" = "26" | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| targets: ${{ matrix.rust-target }} | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| key: native-app-${{ matrix.target }} | |
| workspaces: . -> target | |
| - uses: taiki-e/install-action@v2 | |
| with: | |
| tool: cargo-binstall | |
| - name: Install Tauri CLI | |
| # tauri-cli publishes prebuilt binaries with binstall metadata — a | |
| # seconds-long download instead of a from-source build. Fall back | |
| # to compiling only if binary resolution ever breaks. | |
| # | |
| # --targets must be pinned: on Apple Silicon, binstall's Rosetta | |
| # fallback happily grabs the x86_64 cargo-tauri, and Tauri then | |
| # derives sidecar names for the wrong architecture ("resource path | |
| # hypercolor-daemon-x86_64-apple-darwin doesn't exist"). | |
| run: cargo binstall -y --targets "${{ matrix.rust-target }}" "tauri-cli@^2.0.0" || cargo install tauri-cli --version "^2.0.0" --locked | |
| - name: Download web assets | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: web-assets | |
| path: web-assets/ | |
| - name: Determine version | |
| id: version | |
| shell: pwsh | |
| env: | |
| INPUT_RELEASE_VERSION: ${{ inputs.release_version }} | |
| run: | | |
| $ErrorActionPreference = "Stop" | |
| function Get-CargoVersion { | |
| $metadata = cargo metadata --format-version 1 --no-deps | ConvertFrom-Json | |
| return ($metadata.packages | Where-Object { $_.name -eq "hypercolor-daemon" }).version | |
| } | |
| if ($env:GITHUB_REF_TYPE -eq "tag") { | |
| $version = $env:GITHUB_REF_NAME -replace '^v', '' | |
| } else { | |
| $version = $env:INPUT_RELEASE_VERSION -replace '^v', '' | |
| if ([string]::IsNullOrWhiteSpace($version)) { | |
| $version = "$(Get-CargoVersion)-ci.0" | |
| } | |
| } | |
| if ($version -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$') { | |
| throw "Invalid release version: $version" | |
| } | |
| $cargoVersion = Get-CargoVersion | |
| $baseVersion = ($version -split '-', 2)[0] | |
| if ($baseVersion -ne $cargoVersion) { | |
| throw "Release version $version does not match Cargo version $cargoVersion" | |
| } | |
| "version=$version" >> $env:GITHUB_OUTPUT | |
| - name: Patch app version from tag | |
| shell: pwsh | |
| run: | | |
| $configPath = "crates/hypercolor-app/tauri.conf.json" | |
| $config = Get-Content -Raw $configPath | ConvertFrom-Json | |
| $config.version = "${{ steps.version.outputs.version }}" | |
| $config | ConvertTo-Json -Depth 100 | Set-Content -NoNewline $configPath | |
| - name: Install nasm | |
| if: matrix.target == 'macos-x64' | |
| run: brew install nasm | |
| - name: Build native sidecar binaries | |
| shell: pwsh | |
| run: | | |
| # A pwsh step only fails on the LAST command's exit code, so a | |
| # failed daemon build sails through if a later build succeeds — | |
| # check every invocation explicitly. Splatting needs a variable | |
| # (@rest); an inline @(...) collapses into a single argument. | |
| function Invoke-Checked { | |
| $cmd, $rest = $args | |
| & $cmd @rest | |
| if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } | |
| } | |
| if ($env:RUNNER_OS -eq "Windows") { | |
| Invoke-Checked ./scripts/cargo-cache-build.ps1 cargo build --release --locked -p hypercolor-daemon --features servo | |
| Invoke-Checked ./scripts/cargo-cache-build.ps1 cargo build --release --locked -p hypercolor-cli | |
| } else { | |
| Invoke-Checked bash ./scripts/cargo-cache-build.sh cargo build --release --locked -p hypercolor-daemon --features servo | |
| Invoke-Checked bash ./scripts/cargo-cache-build.sh cargo build --release --locked -p hypercolor-cli | |
| } | |
| if ($env:RUNNER_OS -eq "Windows") { | |
| Invoke-Checked ./scripts/cargo-cache-build.ps1 cargo build --release --locked -p hypercolor-windows-pawnio --bin hypercolor-smbus-service | |
| Invoke-Checked ./scripts/cargo-cache-build.ps1 cargo build --release --locked -p hypercolor-windows-helper --bin hypercolor-windows-helper | |
| } | |
| - name: Stage downloaded web assets | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = "Stop" | |
| function Copy-Tree($Source, $Destination) { | |
| if (Test-Path -LiteralPath $Destination) { | |
| Remove-Item -LiteralPath $Destination -Recurse -Force | |
| } | |
| New-Item -ItemType Directory -Force -Path $Destination | Out-Null | |
| if (Test-Path -LiteralPath $Source) { | |
| Get-ChildItem -LiteralPath $Source -Force | | |
| ForEach-Object { | |
| Copy-Item -LiteralPath $_.FullName -Destination $Destination -Recurse -Force | |
| } | |
| } | |
| } | |
| Copy-Tree "web-assets/ui" "crates/hypercolor-ui/dist" | |
| Copy-Tree "web-assets/effects" "effects/hypercolor" | |
| - name: Stage app bundle payload | |
| shell: pwsh | |
| run: ./scripts/stage-app-bundle-assets.ps1 | |
| - name: Build Tauri native bundle | |
| working-directory: crates/hypercolor-app | |
| shell: pwsh | |
| env: | |
| TAURI_BUNDLES: ${{ matrix.bundles }} | |
| RUST_TARGET: ${{ matrix.rust-target }} | |
| run: | | |
| $configArgs = @() | |
| if (Test-Path "tauri.bundle.conf.json") { | |
| $configArgs += @("--config", "tauri.bundle.conf.json") | |
| } | |
| if ($env:RUNNER_OS -eq "Windows" -and (Test-Path "tauri.windows.bundle.conf.json")) { | |
| $configArgs += @("--config", "tauri.windows.bundle.conf.json") | |
| } | |
| $buildArgs = @("--ci", "--bundles", $env:TAURI_BUNDLES) | |
| # Public CI proves packaging without receiving release credentials. | |
| $buildArgs += "--no-sign" | |
| if ($env:RUNNER_OS -eq "macOS") { | |
| $buildArgs += @("--target", $env:RUST_TARGET) | |
| } | |
| cargo tauri build @buildArgs @configArgs | |
| - name: Verify unsigned macOS app deployment target | |
| if: matrix.cask_arch != '' | |
| run: | | |
| profile_dir="target/${{ matrix.rust-target }}/release" | |
| app="${profile_dir}/bundle/macos/Hypercolor.app" | |
| ./scripts/verify-macos-deployment-target.sh "${app}" | |
| - name: Upload native release bundle | |
| if: runner.os == 'Windows' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: hypercolor-app-${{ steps.version.outputs.version }}-${{ matrix.target }}-${{ matrix.artifact-kind }} | |
| path: ${{ matrix.artifact-path }} | |
| if-no-files-found: error | |
| - name: Upload unsigned macOS packaging fixture | |
| if: runner.os == 'macOS' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: oss-ci-${{ steps.version.outputs.version }}-${{ matrix.target }}-${{ matrix.artifact-kind }} | |
| path: ${{ matrix.artifact-path }} | |
| if-no-files-found: error | |
| retention-days: 7 | |
| # ── Build Release Tarballs ──────────────────────────────────── | |
| build-release-smoke: | |
| name: Release Tarball Smoke (linux-amd64) | |
| if: github.event_name == 'workflow_dispatch' && inputs.release_artifacts == 'smoke' | |
| needs: web-assets | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install system dependencies | |
| run: | | |
| sudo timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| update | |
| sudo env DEBIAN_FRONTEND=noninteractive timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| install -y --no-install-recommends ${{ env.LINUX_DEPS }} librsvg2-bin | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| key: release-smoke-linux-amd64 | |
| - name: Download web assets | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: web-assets | |
| path: web-assets/ | |
| - name: Determine version | |
| id: version | |
| env: | |
| INPUT_RELEASE_VERSION: ${{ inputs.release_version }} | |
| run: | | |
| version="$(./scripts/resolve-release-version.sh "${INPUT_RELEASE_VERSION}")" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| echo "dist_name=hypercolor-${version}-linux-amd64" >> "$GITHUB_OUTPUT" | |
| - name: Assemble distribution | |
| run: | | |
| set -euo pipefail | |
| ./scripts/dist.sh --ci --skip-docs \ | |
| --web-assets web-assets \ | |
| --target linux-amd64 \ | |
| --version "${{ steps.version.outputs.version }}" | |
| test -f "dist/${{ steps.version.outputs.dist_name }}.tar.gz" | |
| - name: Generate release checksum | |
| run: | | |
| set -euo pipefail | |
| dist_name="${{ steps.version.outputs.dist_name }}" | |
| tarball="${dist_name}.tar.gz" | |
| ( | |
| cd dist | |
| sha256sum "${tarball}" > "${tarball}.sha256" | |
| cat "${tarball}.sha256" | |
| ) | |
| - name: Verify release tarball | |
| run: | | |
| dist_name="${{ steps.version.outputs.dist_name }}" | |
| ./scripts/verify-release-artifact.sh \ | |
| "dist/${dist_name}.tar.gz" \ | |
| "dist/${dist_name}.tar.gz.sha256" | |
| - name: Build Debian package | |
| run: | | |
| dist_name="${{ steps.version.outputs.dist_name }}" | |
| ./scripts/package-deb.sh "dist/${dist_name}" dist | |
| - name: Verify Debian package | |
| run: | | |
| mapfile -t debs < <(find dist -maxdepth 1 -type f -name 'hypercolor_*.deb' | sort) | |
| if [ "${#debs[@]}" -ne 1 ]; then | |
| printf 'expected exactly one Debian package, found %s\n' "${#debs[@]}" >&2 | |
| printf '%s\n' "${debs[@]}" >&2 | |
| exit 1 | |
| fi | |
| ./scripts/verify-deb-package.sh "${debs[0]}" | |
| - name: Upload release tarball smoke | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ steps.version.outputs.dist_name }} | |
| path: | | |
| dist/${{ steps.version.outputs.dist_name }}.tar.gz | |
| dist/${{ steps.version.outputs.dist_name }}.tar.gz.sha256 | |
| if-no-files-found: error | |
| retention-days: 7 | |
| - name: Upload Debian package smoke | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: hypercolor-deb-${{ steps.version.outputs.version }}-linux-amd64 | |
| path: dist/hypercolor_*.deb | |
| if-no-files-found: error | |
| retention-days: 7 | |
| build-release: | |
| if: >- | |
| startsWith(github.ref, 'refs/tags/') || | |
| (github.event_name == 'workflow_dispatch' && | |
| inputs.release_artifacts == 'full') | |
| needs: [rust-check-shared, rust-check-macos, rust-test, rust-test-servo, rust-deny, sdk, ui, e2e, web-assets, python, python-generated] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - target: linux-amd64 | |
| os: ubuntu-latest | |
| rust-target: x86_64-unknown-linux-gnu | |
| - target: linux-arm64 | |
| os: ubuntu-24.04-arm | |
| rust-target: aarch64-unknown-linux-gnu | |
| # This lane is cold on every run: it only fires on tag refs, and the cache | |
| # action saves on main alone, so no release-* key is ever written. A cold | |
| # release build of the Servo stack runs well over half an hour on the | |
| # slowest target, and without a ceiling a wedged runner burns GitHub's | |
| # six-hour default before the release reports anything at all. | |
| timeout-minutes: 120 | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Report runner capacity | |
| shell: bash | |
| run: | | |
| echo "cores: $(getconf _NPROCESSORS_ONLN)" | |
| if command -v free >/dev/null 2>&1; then free -h; fi | |
| df -h . | |
| # Fat LTO over the Servo stack peaks past the 15Gi these runners carry, | |
| # and an unresponsive VM gets torn down by the host rather than the | |
| # kernel killing one process, so the build dies with no error to read. | |
| # Disk is the resource sitting spare here, better than 100G of it, so | |
| # spend it: a link that pages is slow, a link that is killed never | |
| # finishes. Nothing about the produced binary changes. | |
| - name: Provision swap for the release link | |
| if: runner.os == 'Linux' | |
| run: | | |
| # Deliberately not set -e. Swap is a cushion, not a correctness | |
| # requirement, so a runner image that refuses one must still get | |
| # its build attempted rather than failing here. | |
| set -uo pipefail | |
| # A separate path from the image's own /swapfile, which is already | |
| # swapped on: allocating over a live swapfile fails with ETXTBSY, | |
| # and this way the image's 3Gi is added to rather than replaced. | |
| swapfile=/hypercolor-swap | |
| if ! sudo fallocate -l 32G "${swapfile}" 2>/dev/null; then | |
| sudo dd if=/dev/zero of="${swapfile}" bs=1M count=32768 status=none | |
| fi | |
| sudo chmod 600 "${swapfile}" | |
| sudo mkswap "${swapfile}" >/dev/null | |
| sudo swapon "${swapfile}" \ | |
| || echo "warning: swap unavailable, link may exhaust memory" | |
| free -h | |
| - name: Install system dependencies (Linux) | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| update | |
| sudo env DEBIAN_FRONTEND=noninteractive timeout --kill-after=30s "$APT_STEP_TIMEOUT" apt-get \ | |
| -o Dpkg::Use-Pty=0 \ | |
| -o Acquire::Retries="$APT_RETRIES" \ | |
| -o Acquire::http::Timeout="$APT_HTTP_TIMEOUT" \ | |
| -o Acquire::https::Timeout="$APT_HTTP_TIMEOUT" \ | |
| install -y --no-install-recommends ${{ env.LINUX_DEPS }} librsvg2-bin | |
| - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable | |
| with: | |
| toolchain: ${{ env.RUST_TOOLCHAIN }} | |
| targets: ${{ matrix.rust-target }} | |
| - uses: ./.github/actions/rust-build-cache | |
| with: | |
| key: release-${{ matrix.target }} | |
| - name: Download web assets | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: web-assets | |
| path: web-assets/ | |
| - name: Determine version | |
| id: version | |
| env: | |
| INPUT_RELEASE_VERSION: ${{ inputs.release_version }} | |
| run: | | |
| VERSION="$(./scripts/resolve-release-version.sh "${INPUT_RELEASE_VERSION}")" | |
| echo "version=${VERSION}" >> "$GITHUB_OUTPUT" | |
| echo "dist_name=hypercolor-${VERSION}-${{ matrix.target }}" >> "$GITHUB_OUTPUT" | |
| - name: Assemble Linux distribution | |
| if: runner.os == 'Linux' | |
| run: | | |
| set -euo pipefail | |
| # Exhausting memory takes the runner down rather than failing a | |
| # command, so nothing after this step survives to report it. Sample | |
| # while the build still has a process to sample from, and keep the | |
| # high-water mark where a truncated log still ends on it. | |
| if command -v free >/dev/null 2>&1; then | |
| ( | |
| peak=0 | |
| swap_peak=0 | |
| while :; do | |
| read -r used avail <<<"$(free -m | awk '/^Mem:/ {print $3, $7}')" | |
| swap="$(free -m | awk '/^Swap:/ {print $3}')" | |
| [ "${used}" -gt "${peak}" ] && peak="${used}" | |
| [ "${swap}" -gt "${swap_peak}" ] && swap_peak="${swap}" | |
| printf '[mem] used=%sMi peak=%sMi avail=%sMi swap=%sMi swap_peak=%sMi\n' \ | |
| "${used}" "${peak}" "${avail}" "${swap}" "${swap_peak}" | |
| sleep 30 | |
| done | |
| ) & | |
| sampler=$! | |
| trap 'kill "${sampler}" 2>/dev/null || true' EXIT | |
| fi | |
| ./scripts/dist.sh --ci --skip-docs \ | |
| --web-assets web-assets \ | |
| --target ${{ matrix.target }} \ | |
| --version "${{ steps.version.outputs.version }}" | |
| test -f "dist/${{ steps.version.outputs.dist_name }}.tar.gz" | |
| # Kept for the failure modes that leave the runner alive. Memory | |
| # exhaustion is not one of them: it takes the whole VM down and every | |
| # remaining step with it, which is why the build step samples inline. | |
| - name: Report runner capacity after failure | |
| if: failure() | |
| shell: bash | |
| run: | | |
| if command -v free >/dev/null 2>&1; then free -h; fi | |
| df -h . | |
| du -sh .cache/hypercolor/target 2>/dev/null || true | |
| - name: Generate release checksum | |
| run: | | |
| set -euo pipefail | |
| dist_name="${{ steps.version.outputs.dist_name }}" | |
| tarball="${dist_name}.tar.gz" | |
| ( | |
| cd dist | |
| if command -v sha256sum >/dev/null 2>&1; then | |
| sha256sum "${tarball}" > "${tarball}.sha256" | |
| else | |
| shasum -a 256 "${tarball}" > "${tarball}.sha256" | |
| fi | |
| cat "${tarball}.sha256" | |
| ) | |
| - name: Verify Linux release tarball | |
| if: runner.os == 'Linux' | |
| run: | | |
| dist_name="${{ steps.version.outputs.dist_name }}" | |
| ./scripts/verify-release-artifact.sh \ | |
| "dist/${dist_name}.tar.gz" \ | |
| "dist/${dist_name}.tar.gz.sha256" | |
| - name: Build Debian package | |
| if: runner.os == 'Linux' | |
| run: | | |
| dist_name="${{ steps.version.outputs.dist_name }}" | |
| ./scripts/package-deb.sh "dist/${dist_name}" dist | |
| - name: Verify Debian package | |
| if: runner.os == 'Linux' | |
| run: | | |
| mapfile -t debs < <(find dist -maxdepth 1 -type f -name 'hypercolor_*.deb' | sort) | |
| if [ "${#debs[@]}" -ne 1 ]; then | |
| printf 'expected exactly one Debian package, found %s\n' "${#debs[@]}" >&2 | |
| printf '%s\n' "${debs[@]}" >&2 | |
| exit 1 | |
| fi | |
| ./scripts/verify-deb-package.sh "${debs[0]}" | |
| - name: Upload release tarball | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ steps.version.outputs.dist_name }} | |
| path: | | |
| dist/${{ steps.version.outputs.dist_name }}.tar.gz | |
| dist/${{ steps.version.outputs.dist_name }}.tar.gz.sha256 | |
| if-no-files-found: error | |
| - name: Upload Debian package | |
| if: runner.os == 'Linux' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: hypercolor-deb-${{ steps.version.outputs.version }}-${{ matrix.target }} | |
| path: dist/hypercolor_*.deb | |
| if-no-files-found: error | |
| # ── Create GitHub Release ────────────────────────────────────── | |
| # Accepts workflow_dispatch as well as push: release.yml pushes tags | |
| # with GITHUB_TOKEN (which never fires `on: push`) and then dispatches | |
| # this workflow on the tag ref. | |
| create-release: | |
| if: >- | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| startsWith(github.ref, 'refs/tags/') | |
| needs: [build-release, build-native-app, python, python-generated] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| actions: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| pattern: hypercolor-* | |
| path: release-artifacts/ | |
| merge-multiple: true | |
| - name: Create GitHub Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| # Only the end-user distributables. The native-app artifacts also | |
| # carry the raw .app bundle tree; an unfiltered find flattens its | |
| # hundreds of internal files into individual release assets. | |
| mapfile -t files < <(find release-artifacts -type f \ | |
| \( -name '*.tar.gz' -o -name '*.tar.gz.sha256' \ | |
| -o -name '*.deb' -o -name '*-setup.exe' \) | sort) | |
| if [ "${#files[@]}" -eq 0 ]; then | |
| echo "No release artifacts found" >&2 | |
| exit 1 | |
| fi | |
| args=( | |
| "${GITHUB_REF_NAME}" | |
| --title "Hypercolor ${GITHUB_REF_NAME#v}" | |
| --verify-tag | |
| ) | |
| # The release commit carries git-iris notes for this tag; fall | |
| # back to GitHub's generated notes if the file is missing. | |
| notes_file=".github/release-notes/${GITHUB_REF_NAME}.md" | |
| if [[ -s "${notes_file}" ]]; then | |
| args+=(--notes-file "${notes_file}") | |
| else | |
| args+=(--generate-notes) | |
| fi | |
| if [[ "${GITHUB_REF_NAME}" == *-* ]]; then | |
| args+=(--prerelease) | |
| fi | |
| gh release create "${args[@]}" "${files[@]}" | |
| # ── Publish npm Packages ─────────────────────────────────────── | |
| # Uses npm trusted publishing (OIDC): no token, and provenance is | |
| # generated automatically. Both packages must have this repo + | |
| # workflow registered as a trusted publisher on npmjs.com. | |
| publish-npm: | |
| if: >- | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| startsWith(github.ref, 'refs/tags/') | |
| needs: [sdk, create-release] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| id-token: write # npm trusted publishing / provenance | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.4.0 | |
| - uses: actions/setup-node@v5 | |
| with: | |
| node-version: 24 | |
| - name: Ensure npm supports trusted publishing | |
| run: | | |
| set -euo pipefail | |
| # OIDC publish needs npm >= 11.5.1. | |
| npm install -g npm@latest | |
| npm --version | |
| - name: Install and build SDK | |
| working-directory: sdk | |
| run: | | |
| set -euo pipefail | |
| bun install --frozen-lockfile | |
| (cd packages/core && bun run build) | |
| - name: Publish packages | |
| working-directory: sdk | |
| run: | | |
| set -euo pipefail | |
| dist_tag="latest" | |
| if [[ "${GITHUB_REF_NAME}" == *-* ]]; then | |
| dist_tag="next" | |
| fi | |
| for dir in packages/core packages/create-effect; do | |
| name="$(jq -r .name "${dir}/package.json")" | |
| version="$(jq -r .version "${dir}/package.json")" | |
| if npm view "${name}@${version}" version >/dev/null 2>&1; then | |
| echo "${name}@${version} already published; skipping" | |
| continue | |
| fi | |
| echo "Publishing ${name}@${version} (tag: ${dist_tag})" | |
| (cd "${dir}" && npm publish --access public --tag "${dist_tag}") | |
| done | |
| # ── Publish PyPI Package ─────────────────────────────────────── | |
| # Requires a PyPI trusted publisher configured for this repo + workflow. | |
| publish-pypi: | |
| if: >- | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| startsWith(github.ref, 'refs/tags/') && | |
| !contains(github.ref_name, '-') | |
| needs: [python-build, create-release] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| id-token: write # PyPI trusted publishing | |
| steps: | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: python-dist | |
| path: dist/ | |
| - name: Publish to PyPI | |
| uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| packages-dir: dist/ | |
| # ── Update AUR Package ──────────────────────────────────────── | |
| update-aur: | |
| if: >- | |
| (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && | |
| startsWith(github.ref, 'refs/tags/') && | |
| !contains(github.ref_name, '-') | |
| needs: create-release | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Determine version | |
| id: version | |
| run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" | |
| - name: Download release artifacts and compute checksums | |
| id: checksums | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p release-artifacts | |
| for platform in linux-amd64 linux-arm64; do | |
| tarball="hypercolor-${VERSION}-${platform}.tar.gz" | |
| gh release download "v${VERSION}" \ | |
| --repo "${{ github.repository }}" \ | |
| --pattern "${tarball}" \ | |
| --dir release-artifacts | |
| sha=$(sha256sum "release-artifacts/${tarball}" | cut -d' ' -f1) | |
| echo "sha256_${platform//-/_}=${sha}" >> "$GITHUB_OUTPUT" | |
| echo " ${platform}: ${sha}" | |
| done | |
| - name: Generate AUR package files | |
| env: | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p aur | |
| sed \ | |
| -e "s/pkgver=.*/pkgver=${VERSION}/" \ | |
| -e "s/SHA256_LINUX_AMD64/${{ steps.checksums.outputs.sha256_linux_amd64 }}/g" \ | |
| -e "s/SHA256_LINUX_ARM64/${{ steps.checksums.outputs.sha256_linux_arm64 }}/g" \ | |
| packaging/aur/PKGBUILD > aur/PKGBUILD | |
| cp packaging/aur/hypercolor.install aur/hypercolor.install | |
| if grep -Eq "SKIP|SHA256_LINUX_" aur/PKGBUILD; then | |
| echo "Generated PKGBUILD still contains checksum placeholders" >&2 | |
| exit 1 | |
| fi | |
| grep -Eq "sha256sums_x86_64=\('[a-f0-9]{64}'\)" aur/PKGBUILD | |
| grep -Eq "sha256sums_aarch64=\('[a-f0-9]{64}'\)" aur/PKGBUILD | |
| echo "Generated AUR PKGBUILD:" | |
| cat aur/PKGBUILD | |
| - name: Generate AUR .SRCINFO | |
| run: | | |
| set -euo pipefail | |
| docker run --rm -v "${PWD}/aur:/pkg" archlinux:base-devel bash -lc ' | |
| set -euo pipefail | |
| useradd -m builder | |
| chown -R builder:builder /pkg | |
| su builder -c "cd /pkg && makepkg --printsrcinfo > .SRCINFO" | |
| ' | |
| - name: Upload AUR package files | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: aur-hypercolor-bin-${{ steps.version.outputs.version }} | |
| path: aur/ | |
| include-hidden-files: true | |
| if-no-files-found: error | |
| - name: Push to AUR | |
| env: | |
| AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} | |
| VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then | |
| echo "AUR_SSH_PRIVATE_KEY is not configured; artifact only." | |
| exit 0 | |
| fi | |
| mkdir -p ~/.ssh | |
| printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur | |
| chmod 600 ~/.ssh/aur | |
| ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts | |
| export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o IdentitiesOnly=yes" | |
| git clone ssh://aur@aur.archlinux.org/hypercolor-bin.git aur-remote | |
| cp aur/PKGBUILD aur/.SRCINFO aur/hypercolor.install aur-remote/ | |
| cd aur-remote | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add .SRCINFO PKGBUILD hypercolor.install | |
| git commit -m "hypercolor-bin: update to ${VERSION}" || exit 0 | |
| git push |