Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
name: Preview Release

# Builds the `preview` channel on every push to main: a co-installable build
# that tracks main, published to a rolling `preview` prerelease and the
# `annot-preview` Homebrew formula. Stable releases are untouched (release.yml).

on:
push:
branches:
- main
workflow_dispatch:

# A push supersedes any in-flight preview build, so the tap always reflects
# the tip of main.
concurrency:
group: preview-release
cancel-in-progress: true

permissions:
contents: write

jobs:
build-preview:
runs-on: macos-26 # Apple Silicon runner (Tahoe)
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history + tags, for the git-describe version

- name: Compute preview version
id: version
run: |
SHA=$(git rev-parse --short HEAD)
if LAST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null); then
BASE=${LAST_TAG#v}
COUNT=$(git rev-list --count "${LAST_TAG}..HEAD")
else
BASE="0.0.0"
COUNT=$(git rev-list --count HEAD)
fi
# Monotonic on COUNT (drives `brew upgrade`); SHA for traceability.
VERSION="${BASE}-preview.${COUNT}.g${SHA}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "Preview version: $VERSION"

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8

- name: Install Node dependencies
run: pnpm install

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri

- name: Import Apple certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)

security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

# Import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH

- name: Build Tauri app (preview channel)
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Baked into the binary at compile time (see src-tauri/src/channel.rs).
ANNOT_CHANNEL: preview
ANNOT_BUILD_SHA: g${{ steps.version.outputs.sha }}
run: pnpm tauri build --config src-tauri/tauri.preview.conf.json

- name: Package artifact
run: |
cd src-tauri/target/release/bundle/macos
mkdir annot-preview
cp -R "annot (preview).app" annot-preview/
tar -czf annot-preview-darwin-arm64.tar.gz annot-preview
mv annot-preview-darwin-arm64.tar.gz "$GITHUB_WORKSPACE/"

- name: Publish rolling preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Delete + recreate so the `preview` tag and asset always point at
# the tip of main (Neovim's nightly pattern).
gh release delete preview --yes --cleanup-tag || true
gh release create preview \
--prerelease \
--target "$GITHUB_SHA" \
--title "Preview build (${{ steps.version.outputs.version }})" \
--notes "Rolling build from \`main\`. Install with \`brew install denolehov/tap/annot-preview\`." \
annot-preview-darwin-arm64.tar.gz

- name: Update annot-preview formula in tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
SHA256=$(shasum -a 256 annot-preview-darwin-arm64.tar.gz | cut -d' ' -f1)
git clone "https://x-access-token:${TAP_TOKEN}@github.com/denolehov/homebrew-tap.git" tap
cat > tap/Formula/annot-preview.rb <<EOF
# typed: true
# frozen_string_literal: true

# Generated by annot's preview.yml workflow. DO NOT EDIT.
class AnnotPreview < Formula
desc "Human-in-the-loop annotation tool for AI workflows (preview channel)"
homepage "https://github.com/denolehov/annot"
version "${VERSION}"
url "https://github.com/denolehov/annot/releases/download/preview/annot-preview-darwin-arm64.tar.gz"
sha256 "${SHA256}"
license "AGPL-3.0"

depends_on :macos
depends_on arch: :arm64

def install
prefix.install "annot (preview).app"
bin.install_symlink prefix/"annot (preview).app/Contents/MacOS/annot-preview"
end

test do
system bin/"annot-preview", "--version"
end
end
EOF
cd tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/annot-preview.rb
if git diff --staged --quiet; then
echo "Formula unchanged, nothing to commit."
else
git commit -m "chore: annot-preview ${VERSION}"
git push
fi
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ jobs:

update-homebrew:
needs: build-macos
# Skip prerelease tags (e.g. v1.2.3-rc.1) so they never overwrite the
# stable annot.rb formula. Preview builds use preview.yml + annot-preview.rb.
if: ${{ !contains(needs.build-macos.outputs.tag, '-') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Expand Down
44 changes: 44 additions & 0 deletions scripts/make-preview-icon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["pillow"]
# ///
"""Derive the `annot (preview)` icon source from the stable icon.

The stable icon is a colored-gradient rounded square with a white asterisk.
Rotating the hue recolors the gradient while leaving the white mark untouched
(white has zero saturation), so preview windows are recognisable at a glance
without maintaining separate artwork.

The output is a single source PNG; feed it to `tauri icon` to generate the
full icon set (mirroring src-tauri/icons/):

uv run scripts/make-preview-icon.py /tmp/preview-src.png
pnpm tauri icon /tmp/preview-src.png --output src-tauri/icons-preview
"""
import sys
import tempfile
from pathlib import Path

from PIL import Image

ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "src-tauri" / "icons" / "icon.png"
DST = (
Path(sys.argv[1])
if len(sys.argv) > 1
else Path(tempfile.gettempdir()) / "annot-preview-icon-source.png"
)

HUE_SHIFT_DEGREES = 120 # recolors green->blue, gold->magenta, purple->green

img = Image.open(SRC).convert("RGBA")
r, g, b, alpha = img.split()
hue, sat, val = Image.merge("RGB", (r, g, b)).convert("HSV").split()
shift = round(HUE_SHIFT_DEGREES / 360 * 256)
hue = hue.point(lambda x: (x + shift) % 256)
recolored = Image.merge("HSV", (hue, sat, val)).convert("RGB")
out = Image.merge("RGBA", (*recolored.split(), alpha))

DST.parent.mkdir(parents=True, exist_ok=True)
out.save(DST)
print(f"wrote {DST}")
Binary file added src-tauri/icons-preview/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/128x128@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square107x107Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square142x142Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square150x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square284x284Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square30x30Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square310x310Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square44x44Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square71x71Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/Square89x89Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src-tauri/icons-preview/StoreLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file added src-tauri/icons-preview/icon.icns
Binary file not shown.
Binary file added src-tauri/icons-preview/icon.ico
Binary file not shown.
Binary file added src-tauri/icons-preview/icon.png
Binary file added src-tauri/icons-preview/ios/AppIcon-20x20@1x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-20x20@2x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-20x20@3x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-29x29@1x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-29x29@2x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-29x29@3x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-40x40@1x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-40x40@2x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-40x40@3x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-512@2x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-60x60@2x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-60x60@3x.png
Binary file added src-tauri/icons-preview/ios/AppIcon-76x76@1x.png
124 changes: 124 additions & 0 deletions src-tauri/src/channel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//! Release channel identity.
//!
//! annot ships two channels: `stable` (tagged releases) and `preview` (builds
//! that track `main`). The channel is baked in at compile time from the
//! `ANNOT_CHANNEL` and `ANNOT_BUILD_SHA` environment variables, which CI sets
//! for preview builds. A normal local or release build leaves them unset and
//! is therefore `stable` — behaviour is byte-identical to a build that never
//! had this module.

use std::sync::OnceLock;

use serde::Serialize;

/// Release channel this binary was built for.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Channel {
/// Tagged release build. The default for any build without CI overrides.
#[default]
Stable,
/// Build tracking `main`; carries the short commit SHA it was built from.
Preview(String),
}

/// Raw channel name baked in at compile time. `"stable"` unless CI overrides it.
const CHANNEL: &str = match option_env!("ANNOT_CHANNEL") {
Some(c) => c,
None => "stable",
};

/// Short commit SHA this binary was built from. `"dev"` for local builds.
const BUILD_SHA: &str = match option_env!("ANNOT_BUILD_SHA") {
Some(s) => s,
None => "dev",
};

/// The channel this binary was built for. Resolved once from compile-time env.
pub fn current() -> &'static Channel {
static CURRENT: OnceLock<Channel> = OnceLock::new();
CURRENT.get_or_init(|| match CHANNEL {
"preview" => Channel::Preview(BUILD_SHA.to_string()),
_ => Channel::Stable,
})
}

impl Channel {
/// Whether this is a non-stable (preview) build.
pub fn is_preview(&self) -> bool {
matches!(self, Channel::Preview(_))
}

/// Human-facing application name. Preview builds carry a visible suffix so
/// stable and preview windows are distinguishable at a glance.
pub fn display_name(&self) -> &'static str {
match self {
Channel::Stable => "annot",
Channel::Preview(_) => "annot (preview)",
}
}

/// Config subdirectory name under the OS config dir. Preview gets its own
/// directory, isolated from stable's tags, exit modes, and bookmarks.
pub fn config_subdir(&self) -> &'static str {
match self {
Channel::Stable => "annot",
Channel::Preview(_) => "annot-preview",
}
}

/// Build marker for non-stable builds, e.g. `"preview · g1a2b3c"`.
/// `None` for stable, so stable output stays unchanged.
pub fn marker(&self) -> Option<String> {
match self {
Channel::Stable => None,
Channel::Preview(sha) => Some(format!("preview · {sha}")),
}
}
}

/// Full version string: the crate version, plus a channel marker for
/// non-stable builds. Used by both `annot version` and clap's `--version`
/// (which requires a `&'static str`), so the result is computed once and cached.
pub fn version_string() -> &'static str {
static CACHE: OnceLock<String> = OnceLock::new();
CACHE
.get_or_init(|| match current().marker() {
Some(marker) => format!("{} ({})", env!("CARGO_PKG_VERSION"), marker),
None => env!("CARGO_PKG_VERSION").to_string(),
})
.as_str()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn stable_is_the_default() {
// The test binary compiles without ANNOT_CHANNEL set, so it is stable.
assert_eq!(*current(), Channel::Stable);
assert!(!current().is_preview());
assert_eq!(current().display_name(), "annot");
assert_eq!(current().config_subdir(), "annot");
assert_eq!(current().marker(), None);
assert_eq!(version_string(), env!("CARGO_PKG_VERSION"));
}

#[test]
fn channels_never_share_a_config_dir() {
// The isolation invariant, independent of which channel compiled this.
assert_ne!(
Channel::Stable.config_subdir(),
Channel::Preview(String::new()).config_subdir()
);
}

#[test]
fn preview_carries_sha_in_marker_and_name() {
let ch = Channel::Preview("g1a2b3c".to_string());
assert!(ch.is_preview());
assert_eq!(ch.marker(), Some("preview · g1a2b3c".to_string()));
assert_eq!(ch.display_name(), "annot (preview)");
}
}
6 changes: 4 additions & 2 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,11 @@ fn save_merged<T: Mergeable + Serialize + DeserializeOwned>(
Ok(())
}

/// Returns the config directory path: ~/.config/annot/ on Unix, %APPDATA%\annot\ on Windows.
/// Returns the config directory path, e.g. `~/.config/annot/` on Unix or
/// `%APPDATA%\annot\` on Windows. Preview builds resolve to an `annot-preview`
/// subdirectory instead, keeping their config isolated from stable's.
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("annot"))
dirs::config_dir().map(|p| p.join(crate::channel::current().config_subdir()))
}

/// Ensures the config directory exists.
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use parking_lot::Mutex;

use tauri::WebviewWindowBuilder;

pub mod channel;
pub mod commands;
pub mod config;
pub mod diff;
Expand Down Expand Up @@ -140,7 +141,7 @@ pub fn run(state: AppState, context: tauri::Context, json_output: bool) {
"main",
tauri::WebviewUrl::App("index.html".into()),
)
.title("annot")
.title(channel::current().display_name())
.inner_size(1000.0, 700.0)
.visible(false); // Will be shown after content loads
#[cfg(target_os = "macos")]
Expand Down
Loading