aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
commitb751d102f158968aab681cb663d27b4c32197f62 (patch)
tree85d03609314559df7705c2cb0ab4e37ab6d9715f
parent295b09ca25edbccac65401ee84fa420dd3888aad (diff)
feat: add preview update channel
-rwxr-xr-x.githooks/commit-msg7
-rw-r--r--.github/ISSUE_TEMPLATE/bug.yml3
-rw-r--r--.github/workflows/ci.yml17
-rw-r--r--.github/workflows/preview.yml329
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml1
-rw-r--r--build.rs3
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/README.md2
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx5
-rw-r--r--docs/next/website/src/content/docs/install.mdx16
-rw-r--r--justfile5
-rw-r--r--scripts/conventional_commits.py74
-rw-r--r--scripts/preview.py298
-rw-r--r--scripts/test_preview.py94
-rw-r--r--src/api/server.rs2
-rw-r--r--src/build_info.rs44
-rw-r--r--src/checksum.rs67
-rw-r--r--src/cli.rs104
-rw-r--r--src/cli/status.rs22
-rw-r--r--src/config.rs2
-rw-r--r--src/config/io.rs16
-rw-r--r--src/config/model.rs46
-rw-r--r--src/main.rs20
-rw-r--r--src/product_announcements.rs4
-rw-r--r--src/release_notes.rs4
-rw-r--r--src/remote.rs276
-rw-r--r--src/server/autodetect.rs2
-rw-r--r--src/server/handoff.rs6
-rw-r--r--src/update.rs419
-rw-r--r--website/.gitignore1
-rw-r--r--website/astro.config.mjs2
-rw-r--r--website/scripts/prepare-docs.mjs80
-rw-r--r--website/src/components/Header.astro149
-rw-r--r--website/src/content.config.ts3
35 files changed, 1994 insertions, 131 deletions
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100755
index 00000000..c8cbadc1
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel)"
+cd "$repo_root"
+
+python3 scripts/conventional_commits.py --message-file "$1"
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 318843cc..6cefded4 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -57,9 +57,10 @@ body:
id: environment
attributes:
label: Environment
- description: Herdr version, operating system, and terminal are required. Shell and relevant config are optional.
+ description: Herdr version, update channel, operating system, and terminal are required. Shell and relevant config are optional.
value: |
- Herdr version:
+ - Update channel (stable or preview):
- Operating system:
- Terminal:
- Shell, if relevant:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 97537d66..cf9655a6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,6 +16,23 @@ concurrency:
cancel-in-progress: true
jobs:
+ conventional-commits:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Validate commit subjects
+ if: github.event_name == 'push'
+ run: python3 scripts/conventional_commits.py --range "${{ github.event.before }}..${{ github.event.after }}"
+
+ - name: Validate PR title
+ if: github.event_name == 'pull_request'
+ run: python3 scripts/conventional_commits.py "${{ github.event.pull_request.title }}"
+
check:
name: check (${{ matrix.os }})
strategy:
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
new file mode 100644
index 00000000..9ea33ca1
--- /dev/null
+++ b/.github/workflows/preview.yml
@@ -0,0 +1,329 @@
+name: Preview
+
+on:
+ workflow_dispatch:
+ inputs:
+ commit:
+ description: Optional master commit SHA to publish
+ required: false
+ type: string
+ schedule:
+ - cron: "0 3 * * 3,5"
+
+permissions:
+ contents: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+concurrency:
+ group: preview-publish
+ cancel-in-progress: false
+
+jobs:
+ preflight:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ should_publish: ${{ steps.plan.outputs.should_publish }}
+ commit: ${{ steps.plan.outputs.commit }}
+ short_sha: ${{ steps.plan.outputs.short_sha }}
+ build_id: ${{ steps.plan.outputs.build_id }}
+ tag: ${{ steps.plan.outputs.tag }}
+ built_at: ${{ steps.plan.outputs.built_at }}
+ base_version: ${{ steps.plan.outputs.base_version }}
+ protocol: ${{ steps.plan.outputs.protocol }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: master
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Select preview commit
+ id: plan
+ shell: bash
+ run: |
+ set -euo pipefail
+ git fetch origin master --tags
+ requested="${{ github.event.inputs.commit || '' }}"
+ if [ -n "$requested" ]; then
+ commit="$(git rev-parse "$requested^{commit}")"
+ if ! git merge-base --is-ancestor "$commit" origin/master; then
+ echo "error: requested commit $commit is not reachable from origin/master" >&2
+ exit 1
+ fi
+ else
+ commit="$(python3 scripts/preview.py select-commit --ref origin/master)"
+ fi
+ current_preview="$(python3 scripts/preview.py current-commit --manifest website/preview.json || true)"
+ if [ "$current_preview" = "$commit" ]; then
+ echo "Preview already points at $commit; skipping."
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ git checkout --detach "$commit"
+ short_sha="$(git rev-parse --short=12 HEAD)"
+ day="$(git show -s --format=%cs HEAD)"
+ built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ build_id="$day-$short_sha"
+ tag="preview-$build_id"
+ base_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
+ protocol="$(python3 -c 'import re; print(re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", open("src/protocol/wire.rs").read()).group(1))')"
+ {
+ echo "should_publish=true"
+ echo "commit=$commit"
+ echo "short_sha=$short_sha"
+ echo "build_id=$build_id"
+ echo "tag=$tag"
+ echo "built_at=$built_at"
+ echo "base_version=$base_version"
+ echo "protocol=$protocol"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Install Rust
+ if: steps.plan.outputs.should_publish == 'true'
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Install Rust tools
+ if: steps.plan.outputs.should_publish == 'true'
+ uses: taiki-e/install-action@b550161ef8a7bc4f2a671c0b03a18ac9ccedea1e # v2
+ with:
+ tool: just,cargo-nextest
+
+ - name: Install Zig
+ if: steps.plan.outputs.should_publish == 'true'
+ uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
+ with:
+ version: 0.15.2
+
+ - name: Restore cargo cache
+ if: steps.plan.outputs.should_publish == 'true'
+ uses: Swatinem/rust-cache@v2
+ with:
+ cache-bin: false
+
+ - name: Run checks
+ if: steps.plan.outputs.should_publish == 'true'
+ run: just check
+
+ build:
+ needs: preflight
+ if: needs.preflight.outputs.should_publish == 'true'
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-musl
+ os: ubuntu-latest
+ name: herdr-linux-x86_64
+ - target: aarch64-unknown-linux-musl
+ os: ubuntu-latest
+ name: herdr-linux-aarch64
+ - target: x86_64-apple-darwin
+ os: macos-latest
+ name: herdr-macos-x86_64
+ - target: aarch64-apple-darwin
+ os: macos-latest
+ name: herdr-macos-aarch64
+ runs-on: ${{ matrix.os }}
+ env:
+ LIBGHOSTTY_VT_OPTIMIZE: ReleaseFast
+ LIBGHOSTTY_VT_SIMD: 'true'
+ HERDR_BUILD_CHANNEL: preview
+ HERDR_BUILD_ID: ${{ needs.preflight.outputs.build_id }}
+ HERDR_BUILD_COMMIT: ${{ needs.preflight.outputs.commit }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.preflight.outputs.commit }}
+ persist-credentials: false
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Install Zig
+ uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
+ with:
+ version: 0.15.2
+
+ - name: Prefer official Ubuntu mirrors over Azure
+ if: runner.os == 'Linux'
+ run: |
+ if [ -f /etc/apt/apt-mirrors.txt ]; then
+ sudo sed -i '/azure.archive.ubuntu.com/d' /etc/apt/apt-mirrors.txt
+ cat /etc/apt/apt-mirrors.txt
+ fi
+
+ - name: Install Linux build tools
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo apt-get install -y cmake ninja-build musl-tools gcc-aarch64-linux-gnu crossbuild-essential-arm64
+
+ - name: Install macOS build tools
+ if: runner.os == 'macOS'
+ run: brew install cmake ninja
+
+ - name: Set Linux aarch64 linker
+ if: matrix.target == 'aarch64-unknown-linux-musl'
+ run: echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
+
+ - name: Cache Rust artifacts
+ uses: Swatinem/rust-cache@v2
+ with:
+ key: preview-${{ matrix.target }}
+
+ - name: Remove Zig caches
+ run: rm -rf .zig-cache vendor/libghostty-vt/.zig-cache vendor/libghostty-vt/zig-out
+
+ - name: Build
+ run: cargo build --release --locked --target ${{ matrix.target }}
+
+ - name: Package artifact
+ shell: bash
+ run: |
+ set -euo pipefail
+ cp target/${{ matrix.target }}/release/herdr ${{ matrix.name }}
+ if [ "${{ runner.os }}" = "Linux" ]; then
+ file ${{ matrix.name }} > BUILD_INFO.txt
+ ldd ${{ matrix.name }} 2>&1 | tee -a BUILD_INFO.txt
+ grep -q "statically linked" BUILD_INFO.txt
+ if nm -u ${{ matrix.name }} 2>/dev/null | grep -E '(__cxa|GLIBCXX|CXXABI|_ZSt)'; then
+ echo "error: Linux artifact has unresolved C++ runtime symbols" >&2
+ exit 1
+ fi
+ else
+ file ${{ matrix.name }} > BUILD_INFO.txt
+ fi
+ python3 - <<'PY' | tee ${{ matrix.name }}.sha256
+ import hashlib, pathlib
+ path = pathlib.Path('${{ matrix.name }}')
+ print(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}")
+ PY
+ {
+ echo "commit=${{ needs.preflight.outputs.commit }}"
+ echo "build_id=${{ needs.preflight.outputs.build_id }}"
+ echo "target=${{ matrix.target }}"
+ echo "channel=preview"
+ } >> BUILD_INFO.txt
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: ${{ matrix.name }}
+ path: |
+ ${{ matrix.name }}
+ ${{ matrix.name }}.sha256
+ BUILD_INFO.txt
+
+ publish:
+ needs: [preflight, build]
+ if: needs.preflight.outputs.should_publish == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: master
+ fetch-depth: 0
+ ssh-key: ${{ secrets.PREVIEW_DEPLOY_KEY }}
+
+ - name: Verify built commit is still current master
+ run: |
+ set -euo pipefail
+ git fetch origin master --tags
+ remote_head="$(git rev-parse origin/master)"
+ if [ "$remote_head" != "${{ needs.preflight.outputs.commit }}" ]; then
+ echo "error: origin/master moved from built commit; aborting preview publish" >&2
+ exit 1
+ fi
+
+ - name: Download all artifacts
+ uses: actions/download-artifact@v8
+ with:
+ path: artifacts
+
+ - name: Generate notes and checksums
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 scripts/preview.py notes \
+ --commit '${{ needs.preflight.outputs.commit }}' \
+ --build-id '${{ needs.preflight.outputs.build_id }}' \
+ --base-version '${{ needs.preflight.outputs.base_version }}' \
+ --output PREVIEW_NOTES.md
+ python3 - <<'PY'
+ import json, pathlib
+ result = {}
+ for path in pathlib.Path('artifacts').glob('herdr-*/*.sha256'):
+ digest, name = path.read_text().split()[:2]
+ target = name.removeprefix('herdr-')
+ result[target] = digest
+ pathlib.Path('preview-sha256.json').write_text(json.dumps(result, indent=2) + '\n')
+ PY
+
+ - name: Create preview prerelease
+ uses: softprops/action-gh-release@v3
+ with:
+ tag_name: ${{ needs.preflight.outputs.tag }}
+ name: Preview build ${{ needs.preflight.outputs.build_id }}
+ body_path: PREVIEW_NOTES.md
+ prerelease: true
+ make_latest: false
+ overwrite_files: true
+ target_commitish: ${{ needs.preflight.outputs.commit }}
+ files: |
+ artifacts/herdr-linux-x86_64/herdr-linux-x86_64
+ artifacts/herdr-linux-aarch64/herdr-linux-aarch64
+ artifacts/herdr-macos-x86_64/herdr-macos-x86_64
+ artifacts/herdr-macos-aarch64/herdr-macos-aarch64
+
+ - name: Update preview manifest
+ run: |
+ python3 scripts/preview.py manifest \
+ --output website/preview.json \
+ --tag '${{ needs.preflight.outputs.tag }}' \
+ --build-id '${{ needs.preflight.outputs.build_id }}' \
+ --commit '${{ needs.preflight.outputs.commit }}' \
+ --built-at '${{ needs.preflight.outputs.built_at }}' \
+ --base-version '${{ needs.preflight.outputs.base_version }}' \
+ --protocol '${{ needs.preflight.outputs.protocol }}' \
+ --notes PREVIEW_NOTES.md \
+ --sha-file preview-sha256.json \
+ --retain 30
+
+ - name: Commit preview manifest
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add website/preview.json
+ git diff --cached --quiet || git commit -m "docs: update preview manifest"
+ git push origin HEAD:master
+
+ - name: Prune old preview prereleases
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,isPrerelease,createdAt > preview-releases.json
+ python3 - <<'PY' > old-preview-tags.txt
+ import json
+ with open("preview-releases.json", encoding="utf-8") as handle:
+ data = json.load(handle)
+ releases = [
+ release for release in data
+ if release.get("isPrerelease") and str(release.get("tagName", "")).startswith("preview-")
+ ]
+ releases.sort(key=lambda release: str(release.get("createdAt", "")), reverse=True)
+ for release in releases[30:]:
+ print(release["tagName"])
+ PY
+ while IFS= read -r tag; do
+ [ -n "$tag" ] || continue
+ gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
+ done < old-preview-tags.txt
diff --git a/Cargo.lock b/Cargo.lock
index fd264314..e8e08cd3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -545,6 +545,7 @@ dependencies = [
"regex",
"serde",
"serde_json",
+ "sha2",
"tokio",
"toml",
"tracing",
diff --git a/Cargo.toml b/Cargo.toml
index b7ef417c..6545f0e8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,6 +24,7 @@ ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+sha2 = "0.10"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
toml = "0.8"
tracing = "0.1.44"
diff --git a/build.rs b/build.rs
index c4037795..fc5bdd64 100644
--- a/build.rs
+++ b/build.rs
@@ -39,6 +39,9 @@ fn main() {
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_OPTIMIZE");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_SIMD");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_ZIG_SYSTEM_DIR");
+ println!("cargo:rerun-if-env-changed=HERDR_BUILD_CHANNEL");
+ println!("cargo:rerun-if-env-changed=HERDR_BUILD_ID");
+ println!("cargo:rerun-if-env-changed=HERDR_BUILD_COMMIT");
println!("cargo:rerun-if-env-changed=ZIG");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 9db3ec9d..bc895693 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Added
+- Added an opt-in preview update channel with `herdr channel set preview`, automated preview manifests, and GitHub prerelease publishing for users who want fixes before stable releases.
- Added `ui.right_click_passthrough_modifier` so a configured modifier such as `ctrl` can forward right-click hold and drag gestures to mouse-reporting pane apps while normal right-click still opens Herdr's pane menu. (#148)
- Added Kilo Code CLI automatic detection for idle, working, and blocked terminal states. (#270)
- Added `herdr integration install copilot` for GitHub Copilot CLI hooks that report prompt, tool, post-approval progress, permission, `ask_user`, `exit_plan_mode`, idle, session-exit state, and session ids through Herdr's socket API. When `[session] resume_agents_on_restore = true` is enabled, Herdr can resume Copilot panes with `copilot --resume=<id>`.
diff --git a/docs/next/README.md b/docs/next/README.md
index 93906b5b..e51823cb 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -67,7 +67,7 @@ Herdr notifies you when a new version is available. Run manually:
herdr update
```
-`herdr update` is for installs managed by Herdr's own installer. Homebrew and Nix installs update through `brew upgrade herdr` or your Nix workflow, then use the same stop-and-run-again flow if a session is still running the old server. See [install docs](https://herdr.dev/docs/install/) and [session state docs](https://herdr.dev/docs/session-state/) for the full update, restart, restore, and handoff matrix.
+`herdr update` is for installs managed by Herdr's own installer. Homebrew and Nix installs update through `brew upgrade herdr` or your Nix workflow, then use the same stop-and-run-again flow if a session is still running the old server. Direct installs can opt into development preview builds with `herdr channel set preview` and return to stable with `herdr channel set stable`. See [install docs](https://herdr.dev/docs/install/) and [session state docs](https://herdr.dev/docs/session-state/) for the full update, restart, restore, and handoff matrix.
## how it compares
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index 383107cf..dd68c0e5 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -17,8 +17,11 @@ herdr --remote workbox --remote-keybindings server
herdr --remote workbox --handoff
herdr --no-session # single-process escape hatch
herdr --default-config # print default config
-herdr update # download and install the latest version
+herdr update # download and install from the configured channel
herdr update --handoff # opt into live handoff for supported running servers
+herdr channel show # print stable or preview
+herdr channel set preview # opt into preview builds
+herdr channel set stable # return to stable releases
herdr --version # print version
```
diff --git a/docs/next/website/src/content/docs/install.mdx b/docs/next/website/src/content/docs/install.mdx
index 409b0d50..ce39b8d2 100644
--- a/docs/next/website/src/content/docs/install.mdx
+++ b/docs/next/website/src/content/docs/install.mdx
@@ -43,6 +43,22 @@ herdr update
`herdr update` is for installs managed by Herdr's own installer. Homebrew and Nix installs are updated through those package managers instead.
+Herdr uses the stable update channel by default. To opt into preview builds from `master`, set the channel first, then update:
+
+```bash
+herdr channel set preview
+herdr update
+```
+
+Switch back to stable the same way:
+
+```bash
+herdr channel set stable
+herdr update
+```
+
+Preview builds are GitHub prereleases published from the current development branch. They are useful when you want fixes before the next stable release, but they can regress. Homebrew and Nix installs do not use the preview channel.
+
By default, `herdr update` installs the new binary and leaves compatible running sessions alone. If an update changes Herdr's client/server protocol, Herdr asks whether to stop the old server after installing. Stop the old server to use the new version. Stopping exits pane processes. For the default session, run `herdr server stop`, then run `herdr` again. For a named session, run `herdr session stop <name>`, then run `herdr session attach <name>` again.
To opt into experimental live server handoff for supported running sessions, run:
diff --git a/justfile b/justfile
index abd976f6..46b5ae9a 100644
--- a/justfile
+++ b/justfile
@@ -3,7 +3,7 @@
# Run tests
test:
cargo nextest run --locked --status-level fail --final-status-level fail --failure-output final --success-output never
- python3 -m unittest scripts.test_changelog scripts.test_vendor_libghostty_vt
+ python3 -m unittest scripts.test_changelog scripts.test_preview scripts.test_vendor_libghostty_vt
# Run one nextest filter, e.g. `just test-one codex_stale_working`
test-one filter:
@@ -20,13 +20,14 @@ ci: lint
# Check formatting + run unit tests + maintenance script tests
check: ci
- python3 -m unittest scripts.test_changelog scripts.test_vendor_libghostty_vt
+ python3 -m unittest scripts.test_changelog scripts.test_preview scripts.test_vendor_libghostty_vt
@echo "docs reminder: if this changes user-facing behavior, make sure the relevant release docs are updated or called out before release."
# Install repo-local git hooks
install-hooks:
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit
+ chmod +x .githooks/commit-msg
@echo "installed git hooks from .githooks"
# Build release binary
diff --git a/scripts/conventional_commits.py b/scripts/conventional_commits.py
new file mode 100644
index 00000000..3c61d49b
--- /dev/null
+++ b/scripts/conventional_commits.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+from pathlib import Path
+
+ALLOWED_TYPES = {
+ "feat",
+ "fix",
+ "perf",
+ "docs",
+ "ci",
+ "test",
+ "refactor",
+ "chore",
+ "release",
+}
+SUBJECT_RE = re.compile(r"^(?P<kind>[a-z]+)(?:\([^)]+\))?!?:\s+\S")
+
+
+def git_subjects(rev_range: str) -> list[str]:
+ output = subprocess.check_output(
+ ["git", "log", "--pretty=format:%s", rev_range], text=True
+ ).strip()
+ return [line.strip() for line in output.splitlines() if line.strip()]
+
+
+def valid_subject(subject: str) -> bool:
+ match = SUBJECT_RE.match(subject)
+ return bool(match and match.group("kind") in ALLOWED_TYPES)
+
+
+def commit_message_subject(path: Path) -> str | None:
+ for line in path.read_text(encoding="utf-8").splitlines():
+ subject = line.strip()
+ if subject and not subject.startswith("#"):
+ return subject
+ return None
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Validate conventional commit subjects")
+ parser.add_argument("subjects", nargs="*")
+ parser.add_argument("--range", dest="rev_range")
+ parser.add_argument("--message-file")
+ args = parser.parse_args()
+
+ subjects = list(args.subjects)
+ if args.rev_range:
+ subjects.extend(git_subjects(args.rev_range))
+ if args.message_file:
+ subject = commit_message_subject(Path(args.message_file))
+ if subject:
+ subjects.append(subject)
+
+ invalid = [subject for subject in subjects if not valid_subject(subject)]
+ if invalid:
+ print("invalid commit subject(s):")
+ for subject in invalid:
+ print(f" {subject}")
+ print(
+ "commit subjects must use conventional commits because preview notes are generated from them."
+ )
+ print("example: fix(update): install selected channel")
+ print("expected: type(optional-scope): subject")
+ print("allowed types: " + ", ".join(sorted(ALLOWED_TYPES)))
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/preview.py b/scripts/preview.py
new file mode 100644
index 00000000..df8589bc
--- /dev/null
+++ b/scripts/preview.py
@@ -0,0 +1,298 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+from pathlib import Path
+from typing import Any
+
+ASSET_TARGETS = (
+ "linux-x86_64",
+ "linux-aarch64",
+ "macos-x86_64",
+ "macos-aarch64",
+)
+EXPECTED_ASSET_NAMES = {target: f"herdr-{target}" for target in ASSET_TARGETS}
+HIDDEN_SUBJECTS = (
+ "release:",
+ "docs: update website manifest",
+ "docs: update preview manifest",
+ "chore: approve contributor",
+ "chore: approve merged contributor",
+)
+TYPE_HEADINGS = {
+ "feat": "Added",
+ "fix": "Fixed",
+ "perf": "Performance",
+ "docs": "Maintenance",
+ "ci": "Maintenance",
+ "test": "Maintenance",
+ "refactor": "Maintenance",
+ "chore": "Maintenance",
+}
+TYPE_ORDER = ("Added", "Fixed", "Performance", "Maintenance", "Other")
+COMMIT_RE = re.compile(r"^(?P<kind>[a-z]+)(?:\([^)]+\))?!?:\s+(?P<body>.+)$")
+
+
+def run_git(args: list[str]) -> str:
+ return subprocess.check_output(["git", *args], text=True).strip()
+
+
+def normalize_version(version: str) -> str:
+ return version.strip().removeprefix("v")
+
+
+def latest_stable_tag() -> str:
+ return run_git(["describe", "--tags", "--match", "v[0-9]*", "--abbrev=0"])
+
+
+def read_json(path: Path) -> dict[str, Any] | None:
+ if not path.exists():
+ return None
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def previous_preview_commit(path: Path) -> str | None:
+ data = read_json(path)
+ if not data:
+ return None
+ commit = data.get("commit")
+ return commit if isinstance(commit, str) and commit.strip() else None
+
+
+def hidden_subject(subject: str) -> bool:
+ lowered = subject.strip().lower()
+ return any(lowered.startswith(prefix) for prefix in HIDDEN_SUBJECTS)
+
+
+def latest_publishable_commit(ref: str) -> str:
+ output = run_git(["log", "--pretty=format:%H%x00%s", ref])
+ for line in output.splitlines():
+ commit, _, subject = line.partition("\x00")
+ if commit and not hidden_subject(subject):
+ return commit
+ raise SystemExit(f"no publishable commit found in {ref}")
+
+
+def commit_subjects(previous: str, commit: str) -> list[str]:
+ output = run_git(["log", "--pretty=format:%s", f"{previous}..{commit}"])
+ if not output:
+ return []
+ subjects = []
+ for line in output.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if hidden_subject(stripped):
+ continue
+ subjects.append(stripped)
+ return subjects
+
+
+def humanize_subject(subject: str) -> tuple[str, str]:
+ match = COMMIT_RE.match(subject)
+ if not match:
+ return "Other", subject[0].upper() + subject[1:]
+ kind = match.group("kind")
+ body = match.group("body").strip()
+ heading = TYPE_HEADINGS.get(kind, "Other")
+ if body:
+ body = body[0].upper() + body[1:]
+ else:
+ body = subject
+ return heading, body
+
+
+def build_notes(previous: str, commit: str, build_id: str, base_version: str, repo: str) -> str:
+ short = commit[:12]
+ compare = f"https://github.com/{repo}/compare/{previous}...{commit}"
+ lines = [
+ f"Preview build {build_id}",
+ "",
+ f"Built from `{short}` on `master`.",
+ f"Base stable: v{normalize_version(base_version)}",
+ f"Compare: {compare}",
+ "",
+ ]
+ grouped: dict[str, list[str]] = {heading: [] for heading in TYPE_ORDER}
+ for subject in commit_subjects(previous, commit):
+ heading, body = humanize_subject(subject)
+ grouped.setdefault(heading, []).append(body)
+
+ wrote = False
+ for heading in TYPE_ORDER:
+ items = grouped.get(heading, [])
+ if not items:
+ continue
+ wrote = True
+ lines.append(f"### {heading}")
+ for item in items:
+ lines.append(f"- {item}")
+ lines.append("")
+
+ if not wrote:
+ lines.extend(["### Changed", "- Rebuilt preview from the current master branch.", ""])
+
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def default_asset_urls(repo: str, tag: str) -> dict[str, str]:
+ return {
+ target: f"https://github.com/{repo}/releases/download/{tag}/{EXPECTED_ASSET_NAMES[target]}"
+ for target in ASSET_TARGETS
+ }
+
+
+def read_sha_file(path: Path | None) -> dict[str, str]:
+ if path is None:
+ return {}
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(data, dict):
+ raise SystemExit("sha file must be a JSON object")
+ return {str(key): str(value) for key, value in data.items()}
+
+
+def asset_objects(urls: dict[str, str], shas: dict[str, str]) -> dict[str, dict[str, str]]:
+ assets: dict[str, dict[str, str]] = {}
+ for target in ASSET_TARGETS:
+ url = urls[target]
+ entry = {"url": url}
+ sha = shas.get(target)
+ if sha:
+ entry["sha256"] = sha
+ assets[target] = entry
+ return assets
+
+
+def build_manifest(
+ output: Path,
+ repo: str,
+ tag: str,
+ build_id: str,
+ commit: str,
+ built_at: str,
+ base_version: str,
+ protocol: int,
+ notes: str,
+ shas: dict[str, str],
+ retain: int,
+) -> str:
+ urls = default_asset_urls(repo, tag)
+ assets = asset_objects(urls, shas)
+ current = read_json(output) or {}
+ builds = current.get("builds") if isinstance(current.get("builds"), dict) else {}
+ builds = dict(builds)
+ builds[build_id] = {
+ "base_version": normalize_version(base_version),
+ "commit": commit,
+ "built_at": built_at,
+ "protocol": protocol,
+ "tag": tag,
+ "assets": assets,
+ }
+ ordered_builds = {
+ key: builds[key]
+ for key in sorted(
+ builds,
+ key=lambda key: str(builds[key].get("built_at", "")),
+ reverse=True,
+ )[:retain]
+ }
+ manifest = {
+ "schema_version": 1,
+ "channel": "preview",
+ "base_version": normalize_version(base_version),
+ "build_id": build_id,
+ "commit": commit,
+ "built_at": built_at,
+ "protocol": protocol,
+ "notes": notes.strip(),
+ "assets": assets,
+ "builds": ordered_builds,
+ }
+ return json.dumps(manifest, indent=2) + "\n"
+
+
+def cmd_notes(args: argparse.Namespace) -> int:
+ previous = args.previous or previous_preview_commit(Path(args.manifest)) or latest_stable_tag()
+ notes = build_notes(previous, args.commit, args.build_id, args.base_version, args.repo)
+ Path(args.output).write_text(notes, encoding="utf-8")
+ return 0
+
+
+def cmd_manifest(args: argparse.Namespace) -> int:
+ notes = Path(args.notes).read_text(encoding="utf-8")
+ shas = read_sha_file(Path(args.sha_file) if args.sha_file else None)
+ content = build_manifest(
+ output=Path(args.output),
+ repo=args.repo,
+ tag=args.tag,
+ build_id=args.build_id,
+ commit=args.commit,
+ built_at=args.built_at,
+ base_version=args.base_version,
+ protocol=args.protocol,
+ notes=notes,
+ shas=shas,
+ retain=args.retain,
+ )
+ Path(args.output).write_text(content, encoding="utf-8")
+ return 0
+
+
+def cmd_current_commit(args: argparse.Namespace) -> int:
+ commit = previous_preview_commit(Path(args.manifest))
+ if commit:
+ print(commit)
+ return 0
+
+
+def cmd_select_commit(args: argparse.Namespace) -> int:
+ print(latest_publishable_commit(args.ref))
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Preview channel release helpers")
+ sub = parser.add_subparsers(required=True)
+
+ notes = sub.add_parser("notes")
+ notes.add_argument("--manifest", default="website/preview.json")
+ notes.add_argument("--previous")
+ notes.add_argument("--commit", required=True)
+ notes.add_argument("--build-id", required=True)
+ notes.add_argument("--base-version", required=True)
+ notes.add_argument("--repo", default="ogulcancelik/herdr")
+ notes.add_argument("--output", required=True)
+ notes.set_defaults(func=cmd_notes)
+
+ manifest = sub.add_parser("manifest")
+ manifest.add_argument("--output", default="website/preview.json")
+ manifest.add_argument("--repo", default="ogulcancelik/herdr")
+ manifest.add_argument("--tag", required=True)
+ manifest.add_argument("--build-id", required=True)
+ manifest.add_argument("--commit", required=True)
+ manifest.add_argument("--built-at", required=True)
+ manifest.add_argument("--base-version", required=True)
+ manifest.add_argument("--protocol", required=True, type=int)
+ manifest.add_argument("--notes", required=True)
+ manifest.add_argument("--sha-file")
+ manifest.add_argument("--retain", type=int, default=30)
+ manifest.set_defaults(func=cmd_manifest)
+
+ current = sub.add_parser("current-commit")
+ current.add_argument("--manifest", default="website/preview.json")
+ current.set_defaults(func=cmd_current_commit)
+
+ select = sub.add_parser("select-commit")
+ select.add_argument("--ref", default="origin/master")
+ select.set_defaults(func=cmd_select_commit)
+
+ args = parser.parse_args()
+ return args.func(args)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test_preview.py b/scripts/test_preview.py
new file mode 100644
index 00000000..1118058b
--- /dev/null
+++ b/scripts/test_preview.py
@@ -0,0 +1,94 @@
+import json
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+import scripts.conventional_commits as conventional_commits
+import scripts.preview as preview
+
+
+class PreviewNotesTests(unittest.TestCase):
+ def test_humanize_groups_conventional_subjects(self):
+ self.assertEqual(
+ preview.humanize_subject("feat(update): add preview channel"),
+ ("Added", "Add preview channel"),
+ )
+ self.assertEqual(
+ preview.humanize_subject("fix: handle preview manifest"),
+ ("Fixed", "Handle preview manifest"),
+ )
+ self.assertEqual(
+ preview.humanize_subject("not conventional"),
+ ("Other", "Not conventional"),
+ )
+
+ def test_build_manifest_archives_current_assets(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ output = Path(tmp) / "preview.json"
+ notes = "Preview notes\n"
+ content = preview.build_manifest(
+ output=output,
+ repo="ogulcancelik/herdr",
+ tag="preview-2026-06-02-abcdef123456",
+ build_id="2026-06-02-abcdef123456",
+ commit="abcdef1234567890",
+ built_at="2026-06-02T03:00:00Z",
+ base_version="0.6.6",
+ protocol=12,
+ notes=notes,
+ shas={"linux-x86_64": "deadbeef"},
+ retain=30,
+ )
+ data = json.loads(content)
+ self.assertEqual(data["channel"], "preview")
+ self.assertEqual(data["build_id"], "2026-06-02-abcdef123456")
+ self.assertEqual(
+ data["assets"]["linux-x86_64"]["sha256"],
+ "deadbeef",
+ )
+ self.assertIn("2026-06-02-abcdef123456", data["builds"])
+
+ def test_hidden_subjects_include_preview_manifest_commits(self):
+ self.assertTrue(preview.hidden_subject("docs: update preview manifest"))
+ self.assertFalse(preview.hidden_subject("fix: repair preview manifest"))
+
+ def test_preview_docs_rewrite_links_to_preview_namespace(self):
+ source = """---
+title: Install Herdr
+---
+
+[Install](/docs/install/)
+file: ../../../public/assets/logo.svg
+"""
+ output = subprocess.check_output(
+ ["node", "website/scripts/prepare-docs.mjs", "--rewrite-preview-doc-fixture"],
+ input=source,
+ text=True,
+ )
+ self.assertIn("[Install](/docs/preview/install/)", output)
+ self.assertIn("file: ../../../../public/assets/logo.svg", output)
+ self.assertIn("Preview docs describe unreleased preview builds", output)
+
+
+class ConventionalCommitTests(unittest.TestCase):
+ def test_valid_subjects_allow_scopes_and_bang(self):
+ self.assertTrue(conventional_commits.valid_subject("fix(update): handle preview"))
+ self.assertTrue(conventional_commits.valid_subject("feat!: change config"))
+ self.assertFalse(conventional_commits.valid_subject("update preview channel"))
+
+ def test_commit_message_subject_skips_comments(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "COMMIT_EDITMSG"
+ path.write_text(
+ "\n# Please enter the commit message\n\nfix(update): switch channel\n",
+ encoding="utf-8",
+ )
+ self.assertEqual(
+ conventional_commits.commit_message_subject(path),
+ "fix(update): switch channel",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/api/server.rs b/src/api/server.rs
index 8b4362fb..c51b46e8 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -252,7 +252,7 @@ fn handle_request(
Method::Ping(_) => serde_json::to_string(&SuccessResponse {
id: request.id,
result: ResponseResult::Pong {
- version: env!("CARGO_PKG_VERSION").into(),
+ version: crate::build_info::version(),
protocol: crate::protocol::PROTOCOL_VERSION,
capabilities,
},
diff --git a/src/build_info.rs b/src/build_info.rs
new file mode 100644
index 00000000..1fdedfbb
--- /dev/null
+++ b/src/build_info.rs
@@ -0,0 +1,44 @@
+//! Build identity helpers.
+
+pub const BASE_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+pub fn channel() -> &'static str {
+ non_empty(option_env!("HERDR_BUILD_CHANNEL")).unwrap_or("stable")
+}
+
+pub fn build_id() -> Option<&'static str> {
+ non_empty(option_env!("HERDR_BUILD_ID"))
+}
+
+pub fn version() -> String {
+ match channel() {
+ "stable" => BASE_VERSION.to_string(),
+ channel => match build_id() {
+ Some(build_id) => format!("{BASE_VERSION}-{channel}.{build_id}"),
+ None => format!("{BASE_VERSION}-{channel}"),
+ },
+ }
+}
+
+pub fn is_preview() -> bool {
+ channel() == "preview"
+}
+
+fn non_empty(value: Option<&'static str>) -> Option<&'static str> {
+ value.and_then(|value| {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed)
+ }
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn stable_version_defaults_to_cargo_version() {
+ assert!(!super::version().is_empty());
+ }
+}
diff --git a/src/checksum.rs b/src/checksum.rs
new file mode 100644
index 00000000..4f61a33c
--- /dev/null
+++ b/src/checksum.rs
@@ -0,0 +1,67 @@
+use std::{
+ fs::File,
+ io::{self, Read},
+ path::Path,
+};
+
+use sha2::{Digest, Sha256};
+
+pub(crate) fn verify_sha256(path: &Path, expected: &str) -> io::Result<()> {
+ let expected = expected.trim().to_ascii_lowercase();
+ if expected.len() != 64 || !expected.chars().all(|ch| ch.is_ascii_hexdigit()) {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "expected sha256 must be 64 hexadecimal characters",
+ ));
+ }
+
+ let actual = file_sha256(path)?;
+ if actual != expected {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("sha256 mismatch: expected {expected}, got {actual}"),
+ ));
+ }
+ Ok(())
+}
+
+fn file_sha256(path: &Path) -> io::Result<String> {
+ let mut file = File::open(path)?;
+ let mut hasher = Sha256::new();
+ let mut buffer = [0u8; 64 * 1024];
+ loop {
+ let read = file.read(&mut buffer)?;
+ if read == 0 {
+ break;
+ }
+ hasher.update(&buffer[..read]);
+ }
+ Ok(to_lower_hex(&hasher.finalize()))
+}
+
+fn to_lower_hex(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for &byte in bytes {
+ output.push(HEX[(byte >> 4) as usize] as char);
+ output.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ #[test]
+ fn verifies_matching_sha256() {
+ let path = std::env::temp_dir().join(format!("herdr-checksum-test-{}", std::process::id()));
+ fs::write(&path, b"herdr").unwrap();
+ let result = super::verify_sha256(
+ &path,
+ "78193ef266c1e3c2ce4ea2a86d7fc87e8c52799653faaac8536533a1c9300f82",
+ );
+ let _ = fs::remove_file(&path);
+ assert!(result.is_ok());
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 2d9dd337..b6e2fc94 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -36,6 +36,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
}
"status" => status::run_status_command(&args[2..])?,
"config" => run_config_command(&args[2..])?,
+ "channel" => run_channel_command(&args[2..])?,
"workspace" => workspace::run_workspace_command(&args[2..])?,
"worktree" => worktree::run_worktree_command(&args[2..])?,
"tab" => tab::run_tab_command(&args[2..])?,
@@ -51,6 +52,89 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
Ok(CommandOutcome::Handled(exit_code))
}
+fn run_channel_command(args: &[String]) -> std::io::Result<i32> {
+ match args.first().map(|arg| arg.as_str()) {
+ Some("set") => channel_set(&args[1..]),
+ Some("show") if args.len() == 1 => {
+ let config = crate::config::Config::load().config;
+ println!("{}", config.update.channel.as_str());
+ Ok(0)
+ }
+ Some("help" | "--help" | "-h") => {
+ print_channel_help();
+ Ok(0)
+ }
+ _ => {
+ print_channel_help();
+ Ok(2)
+ }
+ }
+}
+
+fn channel_set(args: &[String]) -> std::io::Result<i32> {
+ let Some(channel) = parse_channel_set_arg(args) else {
+ eprintln!("usage: herdr channel set <stable|preview>");
+ return Ok(2);
+ };
+
+ let path = crate::config::config_path();
+ let content = if path.exists() {
+ std::fs::read_to_string(&path)?
+ } else {
+ String::new()
+ };
+ if let Err(err) = content.parse::<toml::Value>() {
+ eprintln!(
+ "config file at {} is invalid TOML: {err}. Fix it before changing the update channel.",
+ path.display()
+ );
+ return Ok(1);
+ }
+
+ let updated = crate::config::upsert_section_value(
+ &content,
+ "update",
+ "channel",
+ &format!("\"{channel}\""),
+ );
+ if let Err(err) = updated.parse::<toml::Value>() {
+ eprintln!(
+ "changing the update channel would make {} invalid TOML: {err}; leaving config unchanged",
+ path.display()
+ );
+ return Ok(1);
+ }
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::write(&path, updated)?;
+ println!(
+ "Herdr update channel set to {channel} in {}.",
+ path.display()
+ );
+ if channel == "preview" {
+ println!("Run `herdr update` to install the latest preview build when ready.");
+ } else {
+ println!("Run `herdr update` to install the latest stable release when ready.");
+ }
+ Ok(0)
+}
+
+fn parse_channel_set_arg(args: &[String]) -> Option<&str> {
+ let channel = args.first().map(|arg| arg.as_str())?;
+ if args.len() == 1 && matches!(channel, "stable" | "preview") {
+ Some(channel)
+ } else {
+ None
+ }
+}
+
+fn print_channel_help() {
+ eprintln!("herdr channel commands:");
+ eprintln!(" herdr channel show print the configured update channel");
+ eprintln!(" herdr channel set <stable|preview> choose the update channel");
+}
+
fn run_config_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_config_help();
@@ -745,3 +829,23 @@ fn print_session_help() {
fn _print_json<T: Serialize>(value: &T) {
println!("{}", serde_json::to_string(value).unwrap());
}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn parses_channel_set_argument() {
+ assert_eq!(
+ super::parse_channel_set_arg(&["preview".to_string()]),
+ Some("preview")
+ );
+ assert_eq!(
+ super::parse_channel_set_arg(&["stable".to_string()]),
+ Some("stable")
+ );
+ assert_eq!(super::parse_channel_set_arg(&["nightly".to_string()]), None);
+ assert_eq!(
+ super::parse_channel_set_arg(&["preview".to_string(), "stable".to_string()]),
+ None
+ );
+ }
+}
diff --git a/src/cli/status.rs b/src/cli/status.rs
index 0062367a..348746c5 100644
--- a/src/cli/status.rs
+++ b/src/cli/status.rs
@@ -92,7 +92,11 @@ fn print_full_status(json: bool) -> std::io::Result<i32> {
}
println!("client:");
- println!(" version: {}", env!("CARGO_PKG_VERSION"));
+ println!(" version: {}", crate::build_info::version());
+ println!(
+ " channel: {}",
+ crate::config::Config::load().config.update.channel.as_str()
+ );
println!(" protocol: {}", crate::protocol::PROTOCOL_VERSION);
println!();
println!("server:");
@@ -120,7 +124,11 @@ fn print_client_status(json: bool) -> std::io::Result<()> {
return Ok(());
}
- println!("version: {}", env!("CARGO_PKG_VERSION"));
+ println!("version: {}", crate::build_info::version());
+ println!(
+ "channel: {}",
+ crate::config::Config::load().config.update.channel.as_str()
+ );
println!("protocol: {}", crate::protocol::PROTOCOL_VERSION);
println!("binary: {}", current_exe_label());
Ok(())
@@ -193,7 +201,7 @@ fn compatibility_label(protocol: Option<u32>) -> &'static str {
fn restart_needed_label(server: &ServerRuntimeStatus) -> &'static str {
match server {
ServerRuntimeStatus::Running { version, .. } => match version.as_deref() {
- Some(env!("CARGO_PKG_VERSION")) => "no",
+ Some(version) if version == crate::build_info::version() => "no",
Some(_) => "yes",
None => "unknown",
},
@@ -210,7 +218,8 @@ struct FullStatusJson {
#[derive(Serialize)]
struct ClientStatusJson {
- version: &'static str,
+ version: String,
+ channel: &'static str,
protocol: u32,
binary: String,
session: Option<String>,
@@ -241,7 +250,8 @@ struct UpdateStatusJson {
fn client_status_json() -> ClientStatusJson {
ClientStatusJson {
- version: env!("CARGO_PKG_VERSION"),
+ version: crate::build_info::version(),
+ channel: crate::config::Config::load().config.update.channel.as_str(),
protocol: crate::protocol::PROTOCOL_VERSION,
binary: current_exe_label(),
session: crate::session::active_name(),
@@ -292,7 +302,7 @@ fn update_status_json(server: &ServerRuntimeStatus) -> UpdateStatusJson {
fn restart_needed_bool(server: &ServerRuntimeStatus) -> Option<bool> {
match server {
ServerRuntimeStatus::Running { version, .. } => match version.as_deref() {
- Some(env!("CARGO_PKG_VERSION")) => Some(false),
+ Some(version) if version == crate::build_info::version() => Some(false),
Some(_) => Some(true),
None => None,
},
diff --git a/src/config.rs b/src/config.rs
index ab194cf5..4fc275c1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -20,7 +20,7 @@ pub use self::{
model::{
validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport,
ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ShellModeConfig, ToastConfig,
- ToastDelivery,
+ ToastDelivery, UpdateChannelConfig,
},
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
diff --git a/src/config/io.rs b/src/config/io.rs
index 2c6d78ab..1ea928a3 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -183,6 +183,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
);
load_live_section(
table,
+ "update",
+ "update config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.update = section,
+ );
+ load_live_section(
+ table,
"ui",
"ui config",
&mut diagnostics,
@@ -213,6 +221,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
&mut invalid_sections,
|section| config.experimental = section,
);
+ load_live_section(
+ table,
+ "remote",
+ "remote config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.remote = section,
+ );
Ok(LoadedConfig {
config,
diff --git a/src/config/model.rs b/src/config/model.rs
index f815fb8d..7895341f 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -10,6 +10,37 @@ use super::{
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "lowercase")]
+pub enum UpdateChannelConfig {
+ #[default]
+ Stable,
+ Preview,
+}
+
+impl UpdateChannelConfig {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Stable => "stable",
+ Self::Preview => "preview",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Deserialize)]
+#[serde(default)]
+pub struct UpdateConfig {
+ pub channel: UpdateChannelConfig,
+}
+
+impl Default for UpdateConfig {
+ fn default() -> Self {
+ Self {
+ channel: UpdateChannelConfig::Stable,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
+#[serde(rename_all = "lowercase")]
pub enum ToastDelivery {
#[default]
Off,
@@ -187,6 +218,7 @@ pub struct Config {
pub theme: ThemeConfig,
pub terminal: TerminalConfig,
pub session: SessionConfig,
+ pub update: UpdateConfig,
pub keys: KeysConfig,
pub ui: UiConfig,
pub worktrees: WorktreesConfig,
@@ -589,6 +621,20 @@ mod tests {
use super::*;
#[test]
+ fn update_channel_defaults_stable_and_parses() {
+ let default_config = Config::default();
+ assert_eq!(default_config.update.channel, UpdateChannelConfig::Stable);
+
+ let toml = r#"
+[update]
+channel = "preview"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.update.channel, UpdateChannelConfig::Preview);
+ assert_eq!(config.update.channel.as_str(), "preview");
+ }
+
+ #[test]
fn terminal_default_shell_defaults_empty_and_parses() {
let default_config = Config::default();
assert!(default_config.terminal.default_shell.is_empty());
diff --git a/src/main.rs b/src/main.rs
index d7be42c6..13ba5e6f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,6 +21,8 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [
mod agent_resume;
mod api;
mod app;
+mod build_info;
+mod checksum;
mod cli;
mod client;
mod config;
@@ -95,6 +97,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# "current" for Herdr's process directory, or a fixed path such as "~/Projects".
# new_cwd = "follow"
+[update]
+# Update channel used by background checks and `herdr update`.
+# Use "stable" for normal releases or "preview" for opt-in preview builds.
+# channel = "stable"
+
[keys]
# Prefix key to enter prefix mode (default: "ctrl+b")
# Examples: "ctrl+b", "f12", "esc", "-"
@@ -404,9 +411,11 @@ fn main() -> io::Result<()> {
println!(" herdr --remote <ssh-target> [--session <name>]");
println!(" herdr session attach <name>");
println!(" herdr update [--handoff]");
+ println!(" herdr channel set <stable|preview>");
println!(" herdr server stop");
println!(" herdr server reload-config");
println!(" herdr config <subcommand> ...");
+ println!(" herdr channel <subcommand> ...");
println!(" herdr workspace <subcommand> ...");
println!(" herdr worktree <subcommand> ...");
println!(" herdr tab <subcommand> ...");
@@ -429,6 +438,10 @@ fn main() -> io::Result<()> {
"Stop the running server via the API socket",
),
(
+ "herdr channel set <stable|preview>",
+ "Choose the stable or preview update channel",
+ ),
+ (
"herdr server reload-config",
"Reload config.toml in the running server",
),
@@ -437,6 +450,10 @@ fn main() -> io::Result<()> {
"Back up config.toml and remove custom keybindings",
),
(
+ "herdr channel <subcommand>",
+ "Manage the stable or preview update channel",
+ ),
+ (
"herdr workspace <subcommand>",
"Workspace helpers over the socket API",
),
@@ -491,7 +508,7 @@ fn main() -> io::Result<()> {
}
if args.iter().any(|a| a == "--version" || a == "-V") {
- println!("herdr {}", env!("CARGO_PKG_VERSION"));
+ println!("herdr {}", crate::build_info::version());
return Ok(());
}
@@ -527,6 +544,7 @@ fn main() -> io::Result<()> {
"update",
"status",
"config",
+ "channel",
"workspace",
"worktree",
"pane",
diff --git a/src/product_announcements.rs b/src/product_announcements.rs
index 0d62168c..fe338021 100644
--- a/src/product_announcements.rs
+++ b/src/product_announcements.rs
@@ -99,7 +99,7 @@ pub fn save_manifest_announcement(
pub fn load_unseen_for_current_version() -> Option<ProductAnnouncement> {
load_fake_for_current_version()
- .or_else(|| load_unseen_from_path(&store_path(), env!("CARGO_PKG_VERSION")))
+ .or_else(|| load_unseen_from_path(&store_path(), &crate::build_info::version()))
}
pub fn mark_seen(version: &str, id: &str) -> io::Result<()> {
@@ -155,7 +155,7 @@ fn load_fake_for_current_version() -> Option<ProductAnnouncement> {
.unwrap_or_else(|| "product announcement preview".to_string());
Some(ProductAnnouncement {
- version: env!("CARGO_PKG_VERSION").to_string(),
+ version: crate::build_info::version(),
id,
title,
body,
diff --git a/src/release_notes.rs b/src/release_notes.rs
index aeaef0b8..b211e63d 100644
--- a/src/release_notes.rs
+++ b/src/release_notes.rs
@@ -71,7 +71,7 @@ fn load_stored_from_path(path: &Path) -> Option<StoredReleaseNotes> {
}
pub fn load_latest() -> Option<ReleaseNotes> {
- load_latest_from_path(&pending_path(), env!("CARGO_PKG_VERSION"))
+ load_latest_from_path(&pending_path(), &crate::build_info::version())
}
fn load_latest_from_path(path: &Path, current_version: &str) -> Option<ReleaseNotes> {
@@ -104,7 +104,7 @@ fn release_notes_from_stored(
}
pub fn mark_current_version_seen() -> std::io::Result<()> {
- mark_current_version_seen_at(&pending_path(), env!("CARGO_PKG_VERSION"))
+ mark_current_version_seen_at(&pending_path(), &crate::build_info::version())
}
fn mark_current_version_seen_at(path: &Path, current_version: &str) -> std::io::Result<()> {
diff --git a/src/remote.rs b/src/remote.rs
index ebea55e0..cd53344b 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -19,9 +19,9 @@ const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
-const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION;
-const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
@@ -302,11 +302,36 @@ impl RemoteHerdr {
}
}
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+enum RemoteAssetRef {
+ Url(String),
+ Object { url: String, sha256: Option<String> },
+}
+
+impl RemoteAssetRef {
+ fn url(&self) -> &str {
+ match self {
+ Self::Url(url) => url,
+ Self::Object { url, .. } => url,
+ }
+ }
+
+ fn sha256(&self) -> Option<&str> {
+ match self {
+ Self::Url(_) => None,
+ Self::Object { sha256, .. } => {
+ sha256.as_deref().filter(|value| !value.trim().is_empty())
+ }
+ }
+ }
+}
+
#[derive(Deserialize)]
struct RemoteUpdateManifest {
version: String,
protocol: Option<u32>,
- assets: BTreeMap<String, String>,
+ assets: BTreeMap<String, RemoteAssetRef>,
#[serde(default, deserialize_with = "deserialize_remote_manifest_releases")]
releases: BTreeMap<String, RemoteReleaseMetadata>,
}
@@ -315,7 +340,22 @@ struct RemoteUpdateManifest {
struct RemoteReleaseMetadata {
protocol: Option<u32>,
#[serde(default)]
- assets: BTreeMap<String, String>,
+ assets: BTreeMap<String, RemoteAssetRef>,
+}
+
+#[derive(Deserialize)]
+struct RemotePreviewManifest {
+ build_id: String,
+ protocol: u32,
+ assets: BTreeMap<String, RemoteAssetRef>,
+ #[serde(default)]
+ builds: BTreeMap<String, RemotePreviewBuildMetadata>,
+}
+
+#[derive(Deserialize)]
+struct RemotePreviewBuildMetadata {
+ protocol: u32,
+ assets: BTreeMap<String, RemoteAssetRef>,
}
fn deserialize_remote_manifest_releases<'de, D>(
@@ -359,7 +399,15 @@ impl RemoteUpdateManifest {
#[derive(Clone, Copy)]
struct RemoteManifestReleaseRef<'a> {
protocol: Option<u32>,
- assets: &'a BTreeMap<String, String>,
+ assets: &'a BTreeMap<String, RemoteAssetRef>,
+}
+
+fn current_version() -> String {
+ crate::build_info::version()
+}
+
+fn current_channel() -> &'static str {
+ crate::build_info::channel()
}
struct InstallSource {
@@ -367,6 +415,11 @@ struct InstallSource {
temporary_dir: Option<PathBuf>,
}
+struct RemoteReleaseAsset {
+ url: String,
+ sha256: Option<String>,
+}
+
struct PreparedRemoteHerdr {
remote_herdr: RemoteHerdr,
installed_or_replaced: bool,
@@ -448,8 +501,9 @@ fn prepare_remote_herdr(
if !remote_binary_matches(target, &remote_herdr)? {
return Err(io::Error::other(format!(
- "installed remote herdr at {}, but it did not report version {CURRENT_VERSION}",
- remote_herdr.shell_path
+ "installed remote herdr at {}, but it did not report version {}",
+ remote_herdr.shell_path,
+ current_version()
)));
}
warn_if_remote_bin_not_on_path(target)?;
@@ -519,7 +573,7 @@ fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result
let mut lines = stdout.lines();
let version = lines.next().unwrap_or_default().trim();
let status = lines.next().unwrap_or_default();
- Ok(version == format!("herdr {CURRENT_VERSION}")
+ Ok(version == format!("herdr {}", current_version())
&& parse_client_status_json(status)
.map(|status| status.protocol == CURRENT_PROTOCOL)
.unwrap_or(false))
@@ -585,7 +639,9 @@ fn install_source_description_for(
"the current local herdr binary".to_string()
} else {
format!(
- "the {CURRENT_VERSION} release asset for {}",
+ "the {} {} asset for {}",
+ current_version(),
+ current_channel(),
platform.asset_key()
)
}
@@ -691,7 +747,7 @@ fn remote_server_restart_reason(
if remote_binary_changed {
return Some(RemoteServerRestartReason::BinaryUpdated);
}
- if version != Some(CURRENT_VERSION) {
+ if version != Some(current_version().as_str()) {
return Some(RemoteServerRestartReason::VersionMismatch);
}
None
@@ -750,7 +806,8 @@ fn confirm_remote_install_with_running_server(
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version.as_deref()));
eprintln!(
- "Herdr will install v{CURRENT_VERSION} and hand off live pane processes to the prepared server."
+ "Herdr will install {} and hand off live pane processes to the prepared server.",
+ current_version()
);
return Ok(false);
}
@@ -762,7 +819,10 @@ fn confirm_remote_install_with_running_server(
);
eprintln!("This stops active remote pane processes, including shells, dev servers, and tests.");
eprintln!();
- eprint!("Install v{CURRENT_VERSION} and stop the remote server now? [y/N] ");
+ eprint!(
+ "Install {} and stop the remote server now? [y/N] ",
+ current_version()
+ );
io::stderr().flush()?;
let mut answer = String::new();
@@ -847,15 +907,16 @@ fn confirm_remote_server_stop(
}
eprintln!(
- "remote herdr server on {target} is still running v{}; it will use v{CURRENT_VERSION} after it restarts.",
- version_label(version)
+ "remote herdr server on {target} is still running v{}; it will use {} after it restarts.",
+ version_label(version),
+ current_version()
);
return Ok(false);
}
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version));
- eprintln!(" prepared binary: v{CURRENT_VERSION}");
+ eprintln!(" prepared binary: {}", current_version());
eprintln!();
match reason {
@@ -903,9 +964,11 @@ fn confirm_remote_server_stop(
fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
let command = format!(
- "{} server live-handoff --import-exe {} --expected-protocol {CURRENT_PROTOCOL} --expected-version {CURRENT_VERSION}",
+ "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
remote_herdr.shell_path,
- remote_herdr.shell_path
+ remote_herdr.shell_path,
+ CURRENT_PROTOCOL,
+ current_version()
);
let output = ssh_sh_output(target, &command)?;
if !output.status.success() {
@@ -976,7 +1039,36 @@ fn remote_shell_resolves_managed_install(stdout: &str) -> bool {
}
fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource> {
- let manifest_output = Command::new("curl")
+ let asset_key = platform.asset_key();
+ let asset = remote_release_asset(&asset_key)?;
+
+ let dir = private_download_dir(&asset_key)?;
+ let path = dir.join("herdr.tmp");
+ let status = Command::new("curl")
+ .args(["-sfL", "--max-time", "120", "-o"])
+ .arg(&path)
+ .arg(&asset.url)
+ .status()
+ .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
+ if !status.success() {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(io::Error::other("download failed"));
+ }
+ if let Some(expected) = &asset.sha256 {
+ if let Err(err) = crate::checksum::verify_sha256(&path, expected) {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(io::Error::new(
+ err.kind(),
+ format!("downloaded remote asset checksum verification failed: {err}"),
+ ));
+ }
+ }
+
+ Ok(InstallSource::temporary(path, dir))
+}
+
+fn fetch_remote_manifest(url: &str) -> io::Result<Vec<u8>> {
+ let output = Command::new("curl")
.args([
"-sfL",
"--retry",
@@ -985,54 +1077,87 @@ fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource
"10",
"--max-time",
"20",
- UPDATE_MANIFEST_URL,
+ url,
])
.output()
.map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?;
- if !manifest_output.status.success() {
- return Err(command_failed(
- "failed to fetch update manifest",
- &manifest_output,
- ));
+ if !output.status.success() {
+ return Err(command_failed("failed to fetch update manifest", &output));
}
+ Ok(output.stdout)
+}
- let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_output.stdout)
- .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
+fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset {
+ RemoteReleaseAsset {
+ url: asset.url().to_string(),
+ sha256: asset.sha256().map(str::to_string),
+ }
+}
- let asset_key = platform.asset_key();
- let release = manifest.release_for_version(CURRENT_VERSION).ok_or_else(|| {
+fn preview_assets_for_build<'a>(
+ manifest: &'a RemotePreviewManifest,
+ build_id: &str,
+) -> io::Result<(u32, &'a BTreeMap<String, RemoteAssetRef>)> {
+ if manifest.build_id == build_id {
+ return Ok((manifest.protocol, &manifest.assets));
+ }
+ let build = manifest.builds.get(build_id).ok_or_else(|| {
io::Error::other(format!(
- "release manifest does not include herdr {CURRENT_VERSION}; build herdr for {} or install it there manually",
- platform.asset_key()
+ "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr"
))
})?;
- if let Some(protocol) = release.protocol {
+ Ok((build.protocol, &build.assets))
+}
+
+fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
+ if crate::build_info::is_preview() {
+ let build_id = crate::build_info::build_id().ok_or_else(|| {
+ io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually")
+ })?;
+ let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?;
+ let manifest: RemotePreviewManifest =
+ serde_json::from_slice(&manifest_bytes).map_err(|err| {
+ io::Error::other(format!("failed to parse preview manifest JSON: {err}"))
+ })?;
+ let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?;
if protocol != CURRENT_PROTOCOL {
return Err(io::Error::other(format!(
- "release manifest has herdr {CURRENT_VERSION} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
+ "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually"
)));
}
+ return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| {
+ io::Error::other(format!(
+ "no {asset_key} binary in the preview manifest for build {build_id}"
+ ))
+ });
}
- let url = release.assets.get(&asset_key).ok_or_else(|| {
+
+ let current_version = current_version();
+ let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?;
+ let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes)
+ .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
+ let release = manifest.release_for_version(&current_version).ok_or_else(|| {
io::Error::other(format!(
- "no {asset_key} binary in the release manifest for herdr {CURRENT_VERSION}"
+ "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually",
+ asset_key
))
})?;
-
- let dir = private_download_dir(&asset_key)?;
- let path = dir.join("herdr.tmp");
- let status = Command::new("curl")
- .args(["-sfL", "--max-time", "120", "-o"])
- .arg(&path)
- .arg(url)
- .status()
- .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
- if !status.success() {
- let _ = fs::remove_dir_all(&dir);
- return Err(io::Error::other("download failed"));
+ if let Some(protocol) = release.protocol {
+ if protocol != CURRENT_PROTOCOL {
+ return Err(io::Error::other(format!(
+ "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
+ )));
+ }
}
-
- Ok(InstallSource::temporary(path, dir))
+ release
+ .assets
+ .get(asset_key)
+ .map(remote_asset_info)
+ .ok_or_else(|| {
+ io::Error::other(format!(
+ "no {asset_key} binary in the release manifest for herdr {current_version}"
+ ))
+ })
}
fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
@@ -1063,13 +1188,15 @@ fn confirm_remote_install(
) -> io::Result<()> {
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
- "matching remote herdr {CURRENT_VERSION} is not installed at {}; run from an interactive terminal to approve installation",
+ "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation",
+ current_version(),
remote_herdr.shell_path
)));
}
eprintln!(
- "matching herdr {CURRENT_VERSION} is not installed on {target} for {}.",
+ "matching herdr {} is not installed on {target} for {}.",
+ current_version(),
remote_herdr.platform.asset_key()
);
eprint!(
@@ -2029,7 +2156,7 @@ mod tests {
manifest
.release_for_version("1.2.3")
.and_then(|release| release.assets.get("linux-x86_64"))
- .map(String::as_str),
+ .map(RemoteAssetRef::url),
Some("https://example.com/latest")
);
}
@@ -2058,7 +2185,7 @@ mod tests {
manifest
.release_for_version("1.2.3")
.and_then(|release| release.assets.get("linux-x86_64"))
- .map(String::as_str),
+ .map(RemoteAssetRef::url),
Some("https://example.com/archive")
);
}
@@ -2123,9 +2250,44 @@ mod tests {
}
#[test]
+ fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() {
+ let manifest: RemotePreviewManifest = serde_json::from_str(
+ r#"{
+ "build_id": "2026-06-06-new",
+ "protocol": 12,
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/new",
+ "sha256": "new"
+ }
+ },
+ "builds": {
+ "2026-06-02-old": {
+ "protocol": 11,
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/old",
+ "sha256": "old"
+ }
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ let (protocol, assets) =
+ preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build");
+ let asset = assets.get("linux-x86_64").expect("asset");
+ assert_eq!(protocol, 11);
+ assert_eq!(asset.url(), "https://example.com/old");
+ assert_eq!(asset.sha256(), Some("old"));
+ }
+
+ #[test]
fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(0), false),
+ remote_server_restart_reason(Some(&current_version()), Some(0), false),
Some(RemoteServerRestartReason::ProtocolMismatch)
);
}
@@ -2133,7 +2295,7 @@ mod tests {
#[test]
fn remote_server_restart_reason_offers_restart_after_binary_update() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(CURRENT_PROTOCOL), true),
+ remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), true),
Some(RemoteServerRestartReason::BinaryUpdated)
);
}
@@ -2153,7 +2315,7 @@ mod tests {
#[test]
fn remote_server_restart_reason_allows_current_server() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(CURRENT_PROTOCOL), false),
+ remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), false),
None
);
}
@@ -2187,7 +2349,9 @@ mod tests {
assert_eq!(
install_source_description_for(&platform, None, false),
format!(
- "the {CURRENT_VERSION} release asset for {}",
+ "the {} {} asset for {}",
+ current_version(),
+ current_channel(),
platform.asset_key()
)
);
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
index c012aa03..a4ea721b 100644
--- a/src/server/autodetect.rs
+++ b/src/server/autodetect.rs
@@ -103,7 +103,7 @@ fn validate_running_server_compatibility() -> io::Result<()> {
.protocol
.map(|value| value.to_string())
.unwrap_or_else(|| "unknown".to_string()),
- env!("CARGO_PKG_VERSION"),
+ crate::build_info::version(),
crate::protocol::PROTOCOL_VERSION,
crate::session::active_restart_after_update_guidance()
)))
diff --git a/src/server/handoff.rs b/src/server/handoff.rs
index 22d1f902..01f9e678 100644
--- a/src/server/handoff.rs
+++ b/src/server/handoff.rs
@@ -245,12 +245,12 @@ pub(crate) fn receive(socket_path: &Path, token: &str) -> io::Result<ReceivedHan
if manifest
.expected_version
.as_deref()
- .is_some_and(|version| version != env!("CARGO_PKG_VERSION"))
+ .is_some_and(|version| version != crate::build_info::version())
{
return Err(io::Error::other(format!(
"handoff expected herdr v{}, but this server is v{}",
manifest.expected_version.as_deref().unwrap_or("unknown"),
- env!("CARGO_PKG_VERSION")
+ crate::build_info::version()
)));
}
stream.write_all(b"validated\n")?;
@@ -300,7 +300,7 @@ pub(crate) fn manifest_for(
) -> HandoffManifest {
HandoffManifest {
version: HANDOFF_VERSION,
- source_version: env!("CARGO_PKG_VERSION").to_string(),
+ source_version: crate::build_info::version(),
source_protocol: crate::protocol::PROTOCOL_VERSION,
expected_version,
expected_protocol,
diff --git a/src/update.rs b/src/update.rs
index d05365d1..58095c08 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -17,12 +17,12 @@ use std::time::{Duration, Instant};
use serde::{Deserialize, Deserializer};
-const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
const HOMEBREW_FORMULA_API_URL: &str = "https://formulae.brew.sh/api/formula/herdr.json";
const HERDR_UPDATE_COMMAND: &str = "herdr update";
const HOMEBREW_UPDATE_COMMAND: &str = "brew update && brew upgrade herdr";
const NIX_UPDATE_COMMAND: &str = "update through Nix";
-const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0";
@@ -73,7 +73,7 @@ impl Version {
}
pub fn current() -> Self {
- Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION")
+ Self::parse(crate::build_info::BASE_VERSION).expect("invalid CARGO_PKG_VERSION")
}
}
@@ -87,13 +87,75 @@ impl std::fmt::Display for Version {
// Update manifest
// ---------------------------------------------------------------------------
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum UpdateChannel {
+ Stable,
+ Preview,
+}
+
+impl UpdateChannel {
+ fn configured() -> Self {
+ match crate::config::Config::load().config.update.channel {
+ crate::config::UpdateChannelConfig::Stable => Self::Stable,
+ crate::config::UpdateChannelConfig::Preview => Self::Preview,
+ }
+ }
+
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Stable => "stable",
+ Self::Preview => "preview",
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct AssetRef {
+ url: String,
+ sha256: Option<String>,
+}
+
+impl<'de> Deserialize<'de> for AssetRef {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = serde_json::Value::deserialize(deserializer)?;
+ match value {
+ serde_json::Value::String(url) if !url.trim().is_empty() => Ok(Self {
+ url: url.trim().to_string(),
+ sha256: None,
+ }),
+ serde_json::Value::Object(mut object) => {
+ let url = object
+ .remove("url")
+ .and_then(|value| value.as_str().map(str::to_string))
+ .ok_or_else(|| serde::de::Error::custom("asset object is missing url"))?;
+ let sha256 = object
+ .remove("sha256")
+ .and_then(|value| value.as_str().map(str::to_string));
+ if url.trim().is_empty() {
+ return Err(serde::de::Error::custom("asset url must not be empty"));
+ }
+ Ok(Self {
+ url: url.trim().to_string(),
+ sha256: sha256.filter(|value| !value.trim().is_empty()),
+ })
+ }
+ _ => Err(serde::de::Error::custom(
+ "asset must be a URL string or object with url",
+ )),
+ }
+ }
+}
+
#[derive(Deserialize)]
struct UpdateManifest {
version: String,
/// Thin-client protocol spoken by this release, when advertised by the manifest.
protocol: Option<u32>,
notes: String,
- assets: BTreeMap<String, String>,
+ assets: BTreeMap<String, AssetRef>,
announcement: Option<serde_json::Value>,
#[serde(default, deserialize_with = "deserialize_manifest_releases")]
releases: BTreeMap<String, serde_json::Value>,
@@ -119,6 +181,29 @@ struct ManifestReleaseMetadata {
}
#[derive(Deserialize)]
+struct PreviewManifest {
+ channel: String,
+ base_version: String,
+ build_id: String,
+ commit: String,
+ built_at: String,
+ protocol: u32,
+ notes: String,
+ assets: BTreeMap<String, AssetRef>,
+ #[serde(default)]
+ builds: BTreeMap<String, PreviewBuildMetadata>,
+}
+
+#[derive(Deserialize)]
+struct PreviewBuildMetadata {
+ base_version: String,
+ commit: String,
+ built_at: String,
+ protocol: u32,
+ assets: BTreeMap<String, AssetRef>,
+}
+
+#[derive(Deserialize)]
struct HomebrewFormula {
versions: HomebrewFormulaVersions,
}
@@ -129,8 +214,11 @@ struct HomebrewFormulaVersions {
}
impl UpdateManifest {
+ #[cfg(test)]
fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
- self.assets.get(&format!("{os}-{arch}")).cloned()
+ self.assets
+ .get(&format!("{os}-{arch}"))
+ .map(|asset| asset.url.clone())
}
fn metadata_for_version(&self, version: &Version) -> Option<ManifestReleaseMetadata> {
@@ -164,12 +252,34 @@ impl ManifestReleaseMetadata {
#[derive(Debug, Clone)]
struct ReleaseInfo {
version: Version,
+ identity: String,
+ channel: UpdateChannel,
+ build_id: Option<String>,
+ commit: Option<String>,
target_protocol: Option<u32>,
download_url: String,
+ sha256: Option<String>,
notes_body: String,
}
+impl ReleaseInfo {
+ fn label(&self) -> &str {
+ &self.identity
+ }
+}
+
fn fetch_update_manifest() -> Result<UpdateManifest, String> {
+ fetch_json_manifest(STABLE_UPDATE_MANIFEST_URL)
+}
+
+fn fetch_preview_manifest() -> Result<PreviewManifest, String> {
+ fetch_json_manifest(PREVIEW_UPDATE_MANIFEST_URL)
+}
+
+fn fetch_json_manifest<T>(url: &str) -> Result<T, String>
+where
+ T: serde::de::DeserializeOwned,
+{
let output = Command::new("curl")
.args([
"-sfL",
@@ -179,7 +289,7 @@ fn fetch_update_manifest() -> Result<UpdateManifest, String> {
"10",
"--max-time",
"20",
- UPDATE_MANIFEST_URL,
+ url,
])
.output()
.map_err(|e| format!("curl failed: {e}"))?;
@@ -219,7 +329,7 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas
let latest = Version::parse(&manifest.version)
.ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?;
- if latest <= current {
+ if !stable_channel_should_install(&latest, &current, crate::build_info::is_preview()) {
return Ok(None); // up to date
}
@@ -232,20 +342,117 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas
}
let (os, arch) = platform_target();
- let download_url = manifest
- .download_url_for(os, arch)
- .ok_or_else(|| format!("no binary for {os}-{arch} in update manifest"))?;
+ let asset_key = format!("{os}-{arch}");
+ let asset = manifest
+ .assets
+ .get(&asset_key)
+ .ok_or_else(|| format!("no binary for {asset_key} in update manifest"))?;
+ let download_url = asset.url.clone();
Ok(Some(ReleaseInfo {
+ identity: latest.to_string(),
version: latest,
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol: manifest.protocol,
download_url,
+ sha256: asset.sha256.clone(),
+ notes_body,
+ }))
+}
+
+fn stable_channel_should_install(
+ latest: &Version,
+ current: &Version,
+ installed_is_preview: bool,
+) -> bool {
+ installed_is_preview || latest > current
+}
+
+fn preview_display_version(base_version: &str, build_id: &str) -> String {
+ format!(
+ "{}-preview.{}",
+ base_version.trim_start_matches('v'),
+ build_id
+ )
+}
+
+fn release_info_from_preview_manifest(
+ manifest: &PreviewManifest,
+) -> Result<Option<ReleaseInfo>, String> {
+ if manifest.channel != "preview" {
+ return Err(format!(
+ "invalid preview manifest channel: {}",
+ manifest.channel
+ ));
+ }
+ let build_id = manifest.build_id.trim();
+ if build_id.is_empty() {
+ return Err("preview manifest build_id is empty".into());
+ }
+ if crate::build_info::is_preview()
+ && crate::build_info::build_id().is_some_and(|current| current == build_id)
+ {
+ return Ok(None);
+ }
+
+ let version = Version::parse(&manifest.base_version).ok_or_else(|| {
+ format!(
+ "invalid base_version in preview manifest: {}",
+ manifest.base_version
+ )
+ })?;
+ let notes_body = manifest.notes.trim().to_string();
+ if notes_body.is_empty() {
+ return Err("preview manifest notes are empty".into());
+ }
+ let (os, arch) = platform_target();
+ let asset_key = format!("{os}-{arch}");
+ if let Some(archived) = manifest.builds.get(build_id) {
+ if archived.base_version != manifest.base_version
+ || archived.commit != manifest.commit
+ || archived.built_at != manifest.built_at
+ || archived.protocol != manifest.protocol
+ {
+ tracing::warn!(
+ build_id,
+ "preview manifest archived build metadata differs from top-level metadata"
+ );
+ }
+ }
+ let asset = manifest
+ .assets
+ .get(&asset_key)
+ .or_else(|| {
+ manifest
+ .builds
+ .get(build_id)
+ .and_then(|build| build.assets.get(&asset_key))
+ })
+ .ok_or_else(|| format!("no binary for {asset_key} in preview manifest"))?;
+ let download_url = asset.url.clone();
+
+ Ok(Some(ReleaseInfo {
+ identity: preview_display_version(&manifest.base_version, build_id),
+ version,
+ channel: UpdateChannel::Preview,
+ build_id: Some(build_id.to_string()),
+ commit: Some(manifest.commit.clone()),
+ target_protocol: Some(manifest.protocol),
+ download_url,
+ sha256: asset.sha256.clone(),
notes_body,
}))
}
/// Check the hosted update manifest for the latest release. Returns release info if newer.
fn check_latest() -> Result<Option<ReleaseInfo>, String> {
+ let channel = UpdateChannel::configured();
+ if channel == UpdateChannel::Preview {
+ return release_info_from_preview_manifest(&fetch_preview_manifest()?);
+ }
+
let manifest = fetch_update_manifest()?;
let release = release_info_from_manifest(&manifest)?;
if let Some(release) = &release {
@@ -357,6 +564,16 @@ fn download_update(release: &ReleaseInfo) -> Result<DownloadedUpdate, String> {
return Err("download failed".into());
}
+ if let Some(expected) = &release.sha256 {
+ if let Err(e) = crate::checksum::verify_sha256(&tmp_path, expected) {
+ let _ = fs::remove_file(&tmp_path);
+ return Err(format!(
+ "downloaded update checksum verification failed: {e}"
+ ));
+ }
+ tracing::info!(sha256 = %expected, "downloaded update checksum verified");
+ }
+
// Make executable
#[cfg(unix)]
{
@@ -703,8 +920,8 @@ fn prompt_to_stop_old_servers_before_update(
}
eprintln!(
- "Running sessions that must stop to use v{}:",
- release.version
+ "Running sessions that must stop to use {}:",
+ release.label()
);
for plan in plans {
eprintln!(
@@ -838,8 +1055,9 @@ fn prompt_to_complete_plain_update(
loop {
eprint!(
- "Stop running {} and install v{} now? [y/N] ",
- noun, release.version
+ "Stop running {} and install {} now? [y/N] ",
+ noun,
+ release.label()
);
io::stderr()
.flush()
@@ -899,7 +1117,7 @@ fn print_running_session_update_summary(
);
}
}
- eprintln!(" update: v{}", release.version);
+ eprintln!(" update: {}", release.label());
eprintln!();
}
@@ -927,7 +1145,7 @@ fn runtime_matches_release(status: &crate::api::RuntimeStatus, release: &Release
let protocol_matches = release
.target_protocol
.is_none_or(|protocol| status.protocol == Some(protocol));
- let version_matches = status.version.as_deref() == Some(&release.version.to_string());
+ let version_matches = status.version.as_deref() == Some(release.label());
protocol_matches && version_matches
}
@@ -956,10 +1174,10 @@ fn prompt_to_stop_old_server_after_failed_handoff(
plan.label()
);
eprintln!(" server: v{}", version_label(status.version.as_deref()));
- eprintln!(" installed: v{}", release.version);
+ eprintln!(" installed: {}", release.label());
eprintln!(
- "you can keep using the old server, or stop it now so the next `herdr` start uses v{}.",
- release.version
+ "you can keep using the old server, or stop it now so the next `herdr` start uses {}.",
+ release.label()
);
eprintln!("stopping the old server will exit its pane processes.");
@@ -1024,15 +1242,15 @@ fn recover_failed_live_handoff_for_update(
FailedHandoffServerState::NoServerResponding => {
if let Some(command) = plan.attach_command() {
eprintln!(
- "no herdr server is responding for session {}. the binary was updated; run `{command}` to start v{}.",
+ "no herdr server is responding for session {}. the binary was updated; run `{command}` to start {}.",
plan.label(),
- release.version
+ release.label()
);
} else {
eprintln!(
- "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use v{}.",
+ "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use {}.",
plan.socket_path().display(),
- release.version
+ release.label()
);
}
Ok(RunningServerUpdateOutcome::FailedHandoffNoServer)
@@ -1154,7 +1372,7 @@ fn live_handoff_server_via_api_for_release_at(
let params = ServerLiveHandoffParams {
import_exe: Some(updated_exe.display().to_string()),
expected_protocol: release.target_protocol,
- expected_version: Some(release.version.to_string()),
+ expected_version: Some(release.label().to_string()),
};
send_server_update_method_at(
@@ -1236,7 +1454,7 @@ fn wait_for_server_handoff_at(
socket_path,
timeout,
release.target_protocol,
- Some(&release.version.to_string()),
+ Some(release.label()),
)
}
@@ -1341,12 +1559,14 @@ fn print_running_session_update_outcomes(
eprintln!("Stopping exits active pane processes.");
match &outcome.attach_command {
Some(command) => eprintln!(
- "Run `{}`, then run `{command}` when ready to use v{}.",
- outcome.stop_command, release.version
+ "Run `{}`, then run `{command}` when ready to use {}.",
+ outcome.stop_command,
+ release.label()
),
None => eprintln!(
- "Run `{}`, then restart Herdr with the same socket override when ready to use v{}.",
- outcome.stop_command, release.version
+ "Run `{}`, then restart Herdr with the same socket override when ready to use {}.",
+ outcome.stop_command,
+ release.label()
),
}
}
@@ -1499,13 +1719,24 @@ fn homebrew_cellar_keg_root(path: &Path) -> Option<PathBuf> {
/// Manual self-update command (`herdr update`).
pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
+ let channel = UpdateChannel::configured();
if is_homebrew_managed_install() {
+ if channel == UpdateChannel::Preview {
+ return Err(
+ "self-update is disabled for Homebrew installs; preview is only available for direct Herdr installs".into(),
+ );
+ }
return Err(format!(
"self-update is disabled for Homebrew installs; run `{HOMEBREW_UPDATE_COMMAND}`"
));
}
if is_nix_managed_install() {
+ if channel == UpdateChannel::Preview {
+ return Err(
+ "self-update is disabled for Nix installs; preview is only available for direct Herdr installs".into(),
+ );
+ }
return Err(
"self-update is disabled for Nix installs; update with `nix profile upgrade` or update the flake input that provides Herdr".into(),
);
@@ -1515,14 +1746,14 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
return Err("run `herdr update` outside herdr after detaching from the session".into());
}
- eprintln!("checking for updates...");
+ eprintln!("checking {} channel for updates...", channel.as_str());
let current = Version::current();
let release = match check_latest()? {
Some(r) => r,
None => {
- eprintln!("already up to date (v{current})");
+ eprintln!("already up to date ({})", crate::build_info::version());
return Ok(current);
}
};
@@ -1531,15 +1762,16 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
let server_update_decisions =
confirm_running_server_update_action(running_server_plans, &release, options)?;
- eprintln!("downloading v{}...", release.version);
- if let Err(e) =
- crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body)
- {
+ if let Some(commit) = &release.commit {
+ tracing::info!(commit = %commit, build_id = ?release.build_id, "selected preview update build");
+ }
+ eprintln!("downloading {}...", release.label());
+ if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) {
tracing::warn!("failed to save pending release notes: {e}");
}
let downloaded_update = download_update(&release)?;
let updated_exe = downloaded_update.current_exe.clone();
- eprintln!("downloaded v{}", release.version);
+ eprintln!("downloaded {}", release.label());
if !options.live_handoff
&& !prompt_to_complete_plain_update(&server_update_decisions, &release)?
{
@@ -1548,7 +1780,7 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
return Ok(current);
}
install_downloaded_update(downloaded_update)?;
- eprintln!("installed v{}", release.version);
+ eprintln!("installed {}", release.label());
let server_update_decisions = if options.live_handoff {
server_update_decisions
} else {
@@ -1598,12 +1830,23 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
return;
}
+ let configured_channel = UpdateChannel::configured();
if is_homebrew_managed_install() {
+ if configured_channel == UpdateChannel::Preview {
+ crate::logging::update_check_failed(
+ "preview channel is not available for Homebrew installs",
+ );
+ return;
+ }
auto_update_homebrew(events);
return;
}
let nix_managed_install = is_nix_managed_install();
+ if nix_managed_install && configured_channel == UpdateChannel::Preview {
+ crate::logging::update_check_failed("preview channel is not available for Nix installs");
+ return;
+ }
let release = match check_latest() {
Ok(Some(r)) => r,
@@ -1614,22 +1857,20 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
}
};
- crate::logging::update_available(&release.version.to_string());
+ crate::logging::update_available(release.label());
tracing::info!(
- "new version v{} available at {}",
- release.version,
+ "new {} build available at {}",
+ release.channel.as_str(),
release.download_url
);
- if let Err(e) =
- crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body)
- {
+ if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) {
tracing::warn!("failed to save pending release notes: {e}");
}
tracing::info!(
- "auto-update check: v{} available, waiting for explicit install",
- release.version
+ "auto-update check: {} available, waiting for explicit install",
+ release.label()
);
// Notify the TUI — blocking_send is safe from a std::thread
@@ -1640,7 +1881,7 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
};
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
- version: release.version.to_string(),
+ version: release.label().to_string(),
install_command: install_command.to_string(),
});
}
@@ -1792,8 +2033,13 @@ mod tests {
fn fake_release(version: &str, target_protocol: Option<u32>) -> ReleaseInfo {
ReleaseInfo {
version: Version::parse(version).unwrap(),
+ identity: version.to_string(),
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol,
download_url: "https://example.com/herdr".to_string(),
+ sha256: None,
notes_body: "### Changed\n- One".to_string(),
}
}
@@ -2071,8 +2317,13 @@ mod tests {
};
let compatible_release = ReleaseInfo {
version: Version::parse("0.5.6").unwrap(),
+ identity: "0.5.6".to_string(),
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol: Some(2),
download_url: "https://example.com/herdr".to_string(),
+ sha256: None,
notes_body: "### Changed\n- One".to_string(),
};
let incompatible_release = ReleaseInfo {
@@ -2305,8 +2556,13 @@ mod tests {
};
let release = ReleaseInfo {
version: Version::parse("0.5.6").unwrap(),
+ identity: "0.5.6".to_string(),
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol: Some(3),
download_url: "https://example.com/herdr".to_string(),
+ sha256: None,
notes_body: "### Changed\n- One".to_string(),
};
let plan = RunningServerUpdatePlan {
@@ -2435,8 +2691,13 @@ mod tests {
});
let release = ReleaseInfo {
version: Version::parse("9.8.7").unwrap(),
+ identity: "9.8.7".to_string(),
+ channel: UpdateChannel::Stable,
+ build_id: None,
+ commit: None,
target_protocol: Some(77),
download_url: "https://example.com/herdr".to_string(),
+ sha256: None,
notes_body: "### Changed\n- One".to_string(),
};
@@ -2722,6 +2983,67 @@ mod tests {
}
#[test]
+ fn stable_channel_installs_stable_asset_when_current_binary_is_preview() {
+ let older_stable = Version::parse("0.6.5").unwrap();
+ let preview_base = Version::parse("0.6.6").unwrap();
+ assert!(stable_channel_should_install(
+ &older_stable,
+ &preview_base,
+ true
+ ));
+ assert!(!stable_channel_should_install(
+ &older_stable,
+ &preview_base,
+ false
+ ));
+ }
+
+ #[test]
+ fn preview_manifest_reports_update_when_build_id_differs() {
+ let manifest: PreviewManifest = serde_json::from_str(
+ r####"{
+ "channel": "preview",
+ "base_version": "9.9.9",
+ "build_id": "2026-06-02-abcdef123456",
+ "commit": "abcdef1234567890",
+ "built_at": "2026-06-02T03:00:00Z",
+ "protocol": 77,
+ "notes": "### Fixed\n- One",
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/herdr-linux-x86_64",
+ "sha256": "deadbeef"
+ }
+ },
+ "builds": {
+ "2026-06-02-abcdef123456": {
+ "base_version": "9.9.9",
+ "commit": "abcdef1234567890",
+ "built_at": "2026-06-02T03:00:00Z",
+ "protocol": 77,
+ "assets": {
+ "linux_x86_64": {
+ "url": "https://example.com/herdr-linux_x86_64",
+ "sha256": "deadbeef"
+ }
+ }
+ }
+ }
+ }"####,
+ )
+ .unwrap();
+
+ let release = release_info_from_preview_manifest(&manifest)
+ .unwrap()
+ .expect("preview update");
+
+ assert_eq!(release.channel, UpdateChannel::Preview);
+ assert_eq!(release.identity, "9.9.9-preview.2026-06-02-abcdef123456");
+ assert_eq!(release.target_protocol, Some(77));
+ assert_eq!(release.sha256.as_deref(), Some("deadbeef"));
+ }
+
+ #[test]
fn checked_in_website_manifest_matches_update_schema() {
let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json"))
.expect("website/latest.json should match updater schema");
@@ -2744,10 +3066,11 @@ mod tests {
"macos-x86_64",
"macos-aarch64",
] {
- let url = manifest
+ let url = &manifest
.assets
.get(target)
- .unwrap_or_else(|| panic!("missing asset URL for {target}"));
+ .unwrap_or_else(|| panic!("missing asset URL for {target}"))
+ .url;
assert!(
url.contains(&format!("/releases/download/v{}/", manifest.version)),
"unexpected release URL for {target}: {url}"
diff --git a/website/.gitignore b/website/.gitignore
index 4466e0a1..2d6ddb63 100644
--- a/website/.gitignore
+++ b/website/.gitignore
@@ -2,3 +2,4 @@ node_modules/
public/
dist/
.astro/
+src/content/docs/preview/
diff --git a/website/astro.config.mjs b/website/astro.config.mjs
index badd2873..2da2dd44 100644
--- a/website/astro.config.mjs
+++ b/website/astro.config.mjs
@@ -62,6 +62,7 @@ export default defineConfig({
},
],
components: {
+ Header: './src/components/Header.astro',
SiteTitle: './src/components/SiteTitle.astro',
},
customCss: ['./src/styles/starlight.css'],
@@ -129,6 +130,7 @@ export default defineConfig({
label: 'Updates',
items: [
{ label: 'Releases', link: '/releases/' },
+ { label: 'Preview docs', slug: 'docs/preview' },
{ label: 'Blog', link: '/blog/' },
],
},
diff --git a/website/scripts/prepare-docs.mjs b/website/scripts/prepare-docs.mjs
index c6fefaf7..19201a18 100644
--- a/website/scripts/prepare-docs.mjs
+++ b/website/scripts/prepare-docs.mjs
@@ -1,12 +1,23 @@
-import { cp, mkdir, rm } from 'node:fs/promises';
-import { dirname, resolve } from 'node:path';
+import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import process from 'node:process';
const websiteDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(websiteDir, '../..');
const publicDir = resolve(repoRoot, 'website/public');
+const stableDocsDir = resolve(repoRoot, 'website/src/content/docs');
+const previewDocsSourceDir = resolve(repoRoot, 'docs/next/website/src/content/docs');
+const previewDocsDir = resolve(stableDocsDir, 'preview');
-await preparePublicAssets();
+if (process.argv[2] === '--rewrite-preview-doc-fixture') {
+ const chunks = [];
+ for await (const chunk of process.stdin) chunks.push(chunk);
+ process.stdout.write(rewritePreviewDocContent(Buffer.concat(chunks).toString('utf8')));
+} else {
+ await preparePublicAssets();
+ await preparePreviewDocs();
+}
async function preparePublicAssets() {
await rm(publicDir, { recursive: true, force: true });
@@ -15,11 +26,17 @@ async function preparePublicAssets() {
for (const file of [
'install.sh',
'latest.json',
+ 'preview.json',
'robots.txt',
'_headers',
'_redirects',
]) {
- await cp(resolve(repoRoot, 'website', file), resolve(publicDir, file));
+ const source = resolve(repoRoot, 'website', file);
+ try {
+ await cp(source, resolve(publicDir, file));
+ } catch (error) {
+ if (file !== 'preview.json' || error.code !== 'ENOENT') throw error;
+ }
}
for (const directory of ['assets', 'css']) {
@@ -28,3 +45,58 @@ async function preparePublicAssets() {
});
}
}
+
+async function preparePreviewDocs() {
+ await rm(previewDocsDir, { recursive: true, force: true });
+ await copyPreviewDocs(previewDocsSourceDir, previewDocsDir);
+}
+
+async function copyPreviewDocs(sourceDir, destinationDir) {
+ await mkdir(destinationDir, { recursive: true });
+ for (const entry of await readdir(sourceDir, { withFileTypes: true })) {
+ const source = join(sourceDir, entry.name);
+ const destination = join(destinationDir, entry.name);
+ if (entry.isDirectory()) {
+ await copyPreviewDocs(source, destination);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+
+ const content = await readFile(source, 'utf8');
+ const relativePath = relative(previewDocsSourceDir, source);
+ await writeFile(destination, rewritePreviewDocContent(content, relativePath), 'utf8');
+ }
+}
+
+export function rewritePreviewDocContent(content, relativePath = '') {
+ const rewritten = content
+ .replaceAll('/docs/', '/docs/preview/')
+ .replaceAll('../../../public/', '../../../../public/');
+ return insertPreviewNotice(rewritten, relativePath);
+}
+
+function insertPreviewNotice(content, relativePath) {
+ const notice = [
+ '> Preview docs describe unreleased preview builds. Stable docs remain at [/docs/](/docs/).',
+ '',
+ '',
+ ].join('\n');
+ const indexPrefix =
+ relativePath === 'index.mdx'
+ ? content.replace('title: Herdr documentation', 'title: Herdr preview documentation')
+ : content;
+ const frontmatter = indexPrefix.match(/^---\n[\s\S]*?\n---\n/);
+ if (!frontmatter) {
+ return insertNoticeAfterImports(indexPrefix, notice);
+ }
+ const body = indexPrefix.slice(frontmatter[0].length);
+ return `${frontmatter[0]}\n${insertNoticeAfterImports(body, notice)}`;
+}
+
+function insertNoticeAfterImports(content, notice) {
+ const imports = content.match(/^(\s*import .+?;\n)+\s*/);
+ if (!imports) {
+ return `${notice}${content}`;
+ }
+ return `${imports[0]}${notice}${content.slice(imports[0].length)}`;
+}
diff --git a/website/src/components/Header.astro b/website/src/components/Header.astro
new file mode 100644
index 00000000..982efd2e
--- /dev/null
+++ b/website/src/components/Header.astro
@@ -0,0 +1,149 @@
+---
+import config from 'virtual:starlight/user-config';
+
+import LanguageSelect from 'virtual:starlight/components/LanguageSelect';
+import Search from 'virtual:starlight/components/Search';
+import SiteTitle from 'virtual:starlight/components/SiteTitle';
+import SocialIcons from 'virtual:starlight/components/SocialIcons';
+import ThemeSelect from 'virtual:starlight/components/ThemeSelect';
+
+const shouldRenderSearch =
+ config.pagefind || config.components.Search !== '@astrojs/starlight/components/Search.astro';
+
+const pathname = Astro.url.pathname;
+const isDocs = pathname === '/docs/' || pathname.startsWith('/docs/');
+const isPreview = pathname === '/docs/preview/' || pathname.startsWith('/docs/preview/');
+const stableHref = isPreview ? pathname.replace(/^\/docs\/preview\/?/, '/docs/') : pathname;
+const previewHref = isPreview
+ ? pathname
+ : pathname === '/docs/'
+ ? '/docs/preview/'
+ : pathname.replace(/^\/docs\/?/, '/docs/preview/');
+---
+
+<div class="header">
+ <div class="title-wrapper sl-flex">
+ <SiteTitle />
+ </div>
+ <div class="sl-flex print:hidden search-wrapper">
+ {isDocs && (
+ <nav class="docs-channel-switcher" aria-label="Documentation channel">
+ <a href={stableHref} aria-current={!isPreview ? 'page' : undefined}>Stable</a>
+ <a href={previewHref} aria-current={isPreview ? 'page' : undefined}>Preview</a>
+ </nav>
+ )}
+ {shouldRenderSearch && <Search />}
+ </div>
+ <div class="sl-hidden md:sl-flex print:hidden right-group">
+ <div class="sl-flex social-icons">
+ <SocialIcons />
+ </div>
+ <ThemeSelect />
+ <LanguageSelect />
+ </div>
+</div>
+
+<style>
+ @layer starlight.core {
+ .header {
+ display: flex;
+ gap: var(--sl-nav-gap);
+ justify-content: space-between;
+ align-items: center;
+ height: 100%;
+ }
+
+ .title-wrapper {
+ overflow: clip;
+ padding: 0.25rem;
+ margin: -0.25rem;
+ min-width: 0;
+ }
+
+ .search-wrapper {
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+ }
+
+ .docs-channel-switcher {
+ display: flex;
+ align-items: center;
+ border: 1px solid var(--sl-color-hairline);
+ border-radius: 4px;
+ overflow: hidden;
+ background: var(--sl-color-bg-nav);
+ font-size: var(--sl-text-xs);
+ font-weight: 700;
+ line-height: 1;
+ white-space: nowrap;
+ }
+
+ .docs-channel-switcher a {
+ color: var(--sl-color-gray-2);
+ text-decoration: none;
+ padding: 0.45rem 0.6rem;
+ }
+
+ .docs-channel-switcher a + a {
+ border-inline-start: 1px solid var(--sl-color-hairline);
+ }
+
+ .docs-channel-switcher a[aria-current="page"] {
+ color: var(--sl-color-accent-high);
+ background: color-mix(in srgb, var(--sl-color-accent) 14%, transparent);
+ }
+
+ .docs-channel-switcher a:hover {
+ color: var(--sl-color-accent-high);
+ }
+
+ .right-group,
+ .social-icons {
+ gap: 1rem;
+ align-items: center;
+ }
+
+ .social-icons::after {
+ content: "";
+ height: 2rem;
+ border-inline-end: 1px solid var(--sl-color-gray-5);
+ }
+
+ @media (max-width: 38rem) {
+ .docs-channel-switcher {
+ display: none;
+ }
+ }
+
+ @media (min-width: 50rem) {
+ :global(:root[data-has-sidebar]) {
+ --__sidebar-pad: calc(2 * var(--sl-nav-pad-x));
+ }
+
+ :global(:root:not([data-has-toc])) {
+ --__toc-width: 0rem;
+ }
+
+ .header {
+ --__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x));
+ --__main-column-fr: calc(
+ (
+ 100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) -
+ (2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) -
+ var(--sl-content-width)
+ ) / 2
+ );
+ display: grid;
+ grid-template-columns:
+ minmax(
+ calc(var(--__sidebar-width) + max(0rem, var(--__main-column-fr) - var(--sl-nav-gap))),
+ auto
+ )
+ 1fr
+ auto;
+ align-content: center;
+ }
+ }
+ }
+</style>
diff --git a/website/src/content.config.ts b/website/src/content.config.ts
index c9a9b8fd..834d37cf 100644
--- a/website/src/content.config.ts
+++ b/website/src/content.config.ts
@@ -5,7 +5,8 @@ import { docsSchema } from '@astrojs/starlight/schema';
function docsPath({ entry }: { entry: string }) {
const slug = entry.replace(/\.(md|mdx|markdown|mdown|mkdn|mkd|mdwn)$/i, '');
- return slug === 'index' ? 'docs' : `docs/${slug}`;
+ const normalized = slug.replace(/\/index$/, '');
+ return normalized === 'index' ? 'docs' : `docs/${normalized}`;
}
export const collections = {