Skip to content

feat(hal): drive Lian Li wireless fans and their LCD receivers #1086

feat(hal): drive Lian Li wireless fans and their LCD receivers

feat(hal): drive Lian Li wireless fans and their LCD receivers #1086

Workflow file for this run

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