diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 361fbaa..6c75bd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,44 @@ jobs: echo "share/ is out of date. Run: pnpm generate" exit 1 fi + + native: + name: Appearance probe + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + run: rustup component add clippy rustfmt + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/target + key: cargo-${{ runner.os }}-${{ hashFiles('native/Cargo.lock') }} + + - name: Format + run: cargo fmt --manifest-path native/Cargo.toml --check + + - name: Clippy + run: cargo clippy --manifest-path native/Cargo.toml --all-targets -- -D warnings + + # Includes the pty tests, which fork a real terminal. + - name: Test + run: cargo test --manifest-path native/Cargo.toml + + - name: Verify the probe version matches the release version + run: | + RELEASE=$(jq -r '.version' package.json) + PROBE=$(grep -m1 '^version = ' native/Cargo.toml | cut -d'"' -f2) + if [ "$RELEASE" != "$PROBE" ]; then + echo "native/Cargo.toml is $PROBE but package.json is $RELEASE." + echo "The release workflow builds the binary at the release version." + echo "Run: just sync-version" + exit 1 + fi diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index f2fd833..c37115f 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -26,6 +26,10 @@ jobs: permissions: contents: write pull-requests: write + outputs: + released: ${{ steps.release.outputs.released }} + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.released_version }} steps: - name: Checkout @@ -70,6 +74,10 @@ jobs: # in sync so the release tag (read from root) matches the CLI. CLI_VERSION=$(jq -r '.version' packages/cli/package.json) jq --arg v "$CLI_VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json + # The appearance probe ships as a binary attached to the release, so + # its version has to match the tag it is published under. + sed -i "0,/^version = .*/s//version = \"${CLI_VERSION}\"/" native/Cargo.toml + sed -i "/^name = \"senzu-appearance\"$/{n;s/^version = .*/version = \"${CLI_VERSION}\"/}" native/Cargo.lock VERSION="$CLI_VERSION" echo "version=$VERSION" >> $GITHUB_OUTPUT @@ -88,6 +96,7 @@ jobs: delete-branch: true - name: Release + id: release if: steps.check.outputs.hasChangesets == 'false' || inputs.force_release run: | VERSION=$(jq -r '.version' package.json) @@ -114,5 +123,128 @@ jobs: gh release create "${TAG}" \ --title "${TAG}" \ --generate-notes + + echo "released=true" >> $GITHUB_OUTPUT + echo "tag=${TAG}" >> $GITHUB_OUTPUT + echo "released_version=${VERSION}" >> $GITHUB_OUTPUT env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Build senzu-appearance once per release so no machine has to compile it. + # Linux targets are static musl: no interpreter to patch, runs on NixOS and + # anything else. macOS is built on an arm64 runner. + binaries: + name: Build ${{ matrix.artifact }} + needs: version + if: needs.version.outputs.released == 'true' + runs-on: ${{ matrix.runner }} + permissions: + contents: write + strategy: + fail-fast: true + matrix: + include: + - runner: macos-latest + target: aarch64-apple-darwin + artifact: senzu-appearance-darwin-arm64 + - runner: ubuntu-latest + target: x86_64-unknown-linux-musl + artifact: senzu-appearance-linux-x64 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + artifact: senzu-appearance-linux-arm64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.version.outputs.tag }} + + - name: Install musl toolchain + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Add Rust target + run: rustup target add ${{ matrix.target }} + + # Every runner is native to its target, so the tests that matter (the + # pty ones) actually run on the platform being shipped. + - name: Test + run: cargo test --manifest-path native/Cargo.toml --target ${{ matrix.target }} + + - name: Build + run: cargo build --manifest-path native/Cargo.toml --release --target ${{ matrix.target }} + + - name: Stage artifact + run: | + set -euo pipefail + cp "native/target/${{ matrix.target }}/release/senzu-appearance" "${{ matrix.artifact }}" + chmod +x "${{ matrix.artifact }}" + ./${{ matrix.artifact }} --version + + - name: Compute the nix hash + run: | + set -euo pipefail + HASH="sha256-$(openssl dgst -binary -sha256 '${{ matrix.artifact }}' | openssl base64 -A)" + echo "$HASH" > "${{ matrix.artifact }}.sha256" + echo "$HASH" + + - name: Upload hash artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }}-hash + path: ${{ matrix.artifact }}.sha256 + + - name: Attach to the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ needs.version.outputs.tag }}" "${{ matrix.artifact }}" --clobber + + # Point the flake at the binaries that were just published, so consumers + # fetch them instead of building. + flake-hashes: + name: Update flake hashes + needs: [version, binaries] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + + - name: Download hashes + uses: actions/download-artifact@v4 + with: + path: hashes + merge-multiple: true + + # Version and hashes move together, so every commit on main points at a + # release whose assets exist. + - name: Rewrite flake.nix + run: | + set -euo pipefail + VERSION="${{ needs.version.outputs.version }}" + DARWIN=$(cat hashes/senzu-appearance-darwin-arm64.sha256) + LINUX_ARM=$(cat hashes/senzu-appearance-linux-arm64.sha256) + LINUX_X64=$(cat hashes/senzu-appearance-linux-x64.sha256) + + sed -i "s|appearanceVersion = \"[^\"]*\"; # appearance-version|appearanceVersion = \"${VERSION}\"; # appearance-version|" flake.nix + sed -i "s|hash = \"sha256-[^\"]*\"; # darwin|hash = \"${DARWIN}\"; # darwin|" flake.nix + sed -i "s|hash = \"sha256-[^\"]*\"; # linux-arm64|hash = \"${LINUX_ARM}\"; # linux-arm64|" flake.nix + sed -i "s|hash = \"sha256-[^\"]*\"; # linux-x64|hash = \"${LINUX_X64}\"; # linux-x64|" flake.nix + + - name: Commit + run: | + set -euo pipefail + if git diff --quiet -- flake.nix; then + echo "No hash changes to commit" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add flake.nix + git commit -m "chore: point flake at senzu-appearance binaries for ${{ needs.version.outputs.tag }}" + git push origin main diff --git a/.gitignore b/.gitignore index f3bfeb3..3aaf9c6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules dist out *.tgz +native/target # logs logs diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..a94de09 --- /dev/null +++ b/Justfile @@ -0,0 +1,113 @@ +# senzu - run `just` to list recipes. + +_default: + @just --list --unsorted + +# --- themes ------------------------------------------------------------------ + +# Regenerate every target in share/ from config.json. +generate target="": + pnpm generate {{ target }} + +# List palettes and targets. +list: + pnpm list + +# Symlink themes into a program's config dir, e.g. `just install ghostty senzu`. +install target variant="all": + pnpm install:themes -- install {{ target }} {{ variant }} + +# Preview a palette in the terminal. +preview variant="": + pnpm exec tsx packages/cli/src/cli/index.ts preview {{ variant }} + +# Regenerate the README theme previews. +previews: + pnpm readme:previews + +# --- checks ------------------------------------------------------------------ + +# Everything CI runs. +check: lint typecheck test-native themes-current version-current + +# Biome check. +lint: + pnpm lint + +# Biome check with fixes applied. +format: + pnpm format + +typecheck: + pnpm typecheck + +# Fail if share/ is out of date with config.json and the generators. +themes-current: generate + git diff --exit-code -- share/ + +# Fail if the probe version has drifted from the release version. The release +# workflow builds the binary at the release version, so they must match. +version-current: + #!/usr/bin/env bash + set -euo pipefail + release=$(jq -r '.version' package.json) + probe=$(grep -m1 '^version = ' native/Cargo.toml | cut -d'"' -f2) + if [ "$release" != "$probe" ]; then + echo "native/Cargo.toml is $probe but package.json is $release. Run: just sync-version" + exit 1 + fi + +# Copy the release version into the probe manifest. +sync-version: + #!/usr/bin/env bash + set -euo pipefail + release=$(jq -r '.version' package.json) + sed -i "0,/^version = .*/s//version = \"${release}\"/" native/Cargo.toml + sed -i "/^name = \"senzu-appearance\"$/{n;s/^version = .*/version = \"${release}\"/}" native/Cargo.lock + echo "probe version set to ${release}" + +# --- appearance probe (native/) ---------------------------------------------- + +# Build the probe. +build-native: + cargo build --release --manifest-path native/Cargo.toml + +# Unit, CLI and pty tests. +test-native: + cargo test --manifest-path native/Cargo.toml + +# Terminal-level tests only, with output. +test-pty: + cargo test --manifest-path native/Cargo.toml --test pty -- --nocapture + +fmt-native: + cargo fmt --manifest-path native/Cargo.toml + +clippy: + cargo clippy --manifest-path native/Cargo.toml --all-targets -- -D warnings + +# Run the probe from the working tree, e.g. `just probe "senzu senzu-light"`. +probe args="": + cargo run --quiet --manifest-path native/Cargo.toml -- {{ args }} + +# --- nix --------------------------------------------------------------------- + +# Build the themes package. +build: + nix build .#themes + +# Fetch the released probe binary, as consumers do. +build-probe: + nix build .#appearance + +# Compile the probe locally instead of downloading it. +build-probe-source: + nix build .#appearance-source + +flake-check: + nix flake check + +# --- release ----------------------------------------------------------------- + +changeset: + pnpm changeset diff --git a/flake.nix b/flake.nix index f37869b..8154938 100644 --- a/flake.nix +++ b/flake.nix @@ -34,6 +34,41 @@ "aarch64-linux" "x86_64-linux" ]; + + # Prebuilt senzu-appearance binaries, built by the release workflow and + # attached to the GitHub release. Consumers download instead of + # compiling: a Rust toolchain on every machine, for one small binary, + # every time the input moves, is not a good trade. + # + # The marker comments are load-bearing: .github/workflows/version.yml + # rewrites the version and the hashes together after each release. Do + # not reformat them. + # + # This version is deliberately not `version` above. That one moves with + # the release commit, while the binaries only exist once the build job + # has uploaded them; pinning them separately keeps every commit pointing + # at a release that actually has assets. + appearanceVersion = "0.0.0"; # appearance-version + + # What the hashes hold until a release has uploaded binaries. While they + # are still placeholders the flake builds from source instead of + # fetching something that does not exist. + appearancePlaceholder = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + appearanceBinaries = { + "aarch64-darwin" = { + suffix = "darwin-arm64"; + hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # darwin + }; + "aarch64-linux" = { + suffix = "linux-arm64"; + hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # linux-arm64 + }; + "x86_64-linux" = { + suffix = "linux-x64"; + hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # linux-x64 + }; + }; in systemIndependent // flake-utils.lib.eachSystem systems ( @@ -62,6 +97,59 @@ }; }; + # Terminal appearance probe. The only component allowed to query the + # tty; see docs/appearance-detection.md. + # + # Built from source: for development, and the fallback when a release + # has no binary for a platform. `cargo test` runs in the build, + # including the pty tests. + appearance-source = pkgs.rustPlatform.buildRustPackage { + pname = "senzu-appearance"; + inherit version; + + src = ./native; + + cargoLock.lockFile = ./native/Cargo.lock; + + meta = with pkgs.lib; { + description = "Terminal appearance probe for Senzu"; + license = licenses.mit; + mainProgram = "senzu-appearance"; + platforms = platforms.unix; + }; + }; + + # The released binary. Linux builds are static musl, so there is no + # interpreter to patch and they run on NixOS as-is. + appearance-binary = + let + binary = + appearanceBinaries.${system} + or (throw "senzu-appearance: no prebuilt binary for ${system}; use .#appearance-source"); + in + pkgs.stdenv.mkDerivation { + pname = "senzu-appearance"; + version = appearanceVersion; + + src = pkgs.fetchurl { + url = "https://github.com/aliou/senzu/releases/download/v${appearanceVersion}/senzu-appearance-${binary.suffix}"; + inherit (binary) hash; + }; + + dontUnpack = true; + + installPhase = '' + install -Dm755 $src $out/bin/senzu-appearance + ''; + + meta = with pkgs.lib; { + description = "Terminal appearance probe for Senzu (prebuilt)"; + license = licenses.mit; + mainProgram = "senzu-appearance"; + platforms = builtins.attrNames appearanceBinaries; + }; + }; + # Live `senzu` bin for the devShell: runs the CLI through tsx against the # working tree so edits to generators/config take effect immediately. senzu-dev = pkgs.writeShellScriptBin "senzu" '' @@ -139,6 +227,17 @@ packages = { default = themes; themes = themes; + + # Download when a release has published binaries for this system, + # compile otherwise. Nothing to switch by hand: the release workflow + # fills in the hashes. + appearance = + if (appearanceBinaries.${system}.hash or appearancePlaceholder) == appearancePlaceholder then + appearance-source + else + appearance-binary; + appearance-binary = appearance-binary; + appearance-source = appearance-source; }; devShells.default = pkgs.mkShell { @@ -147,6 +246,13 @@ nodejs_24 pnpm senzu-dev + just + jq + # Appearance probe toolchain. + cargo + rustc + clippy + rustfmt ]; }; }