mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
* feat(gateway): show git commit hash in version display for non-tagged builds
When the binary is not built from an exact git tag, the user info popover
now shows the short commit hash next to the version (e.g. "IronClaw v0.25.0
(88d3f63)"). Modified working trees append "-dirty". Tagged releases show
just the version as before. This lets users report the exact build when
filing bugs.
[skip-regression-check]
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(build): use `git rev-parse --git-dir` for worktree-safe HEAD tracking (#2486)
Address gemini-code-assist review: the hardcoded `.git/HEAD` path
fails inside git worktrees where `.git` is a file, not a directory.
Use `git rev-parse --git-dir` to robustly resolve the actual git
metadata directory in all environments.
* fix(build): collapse nested if to satisfy clippy::collapsible_if
* fix(deps): bump rustls-webpki 0.103.12, ignore RUSTSEC-2026-{0098,0099}
Bump rustls-webpki 0.103.10 → 0.103.12 (fixes the advisory for the
direct dep chain). The 0.101.7 (aws-smithy/rustls 0.21) and 0.102.8
(libsql) versions are pinned by transitive deps and cannot be bumped —
add ignore entries with context.
* fix(build): use --git-common-dir for worktree-safe ref watching (#2486)
In git worktrees, branch refs (e.g. refs/heads/main) live under the
common git directory, not the per-worktree git directory. Resolve refs
via `git rev-parse --git-common-dir` so Cargo watches the correct file
and rebuilds on new commits in worktree environments.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
364 lines
12 KiB
Rust
364 lines
12 KiB
Rust
//! Build script: compile Telegram channel WASM from source.
|
|
//!
|
|
//! Do not commit compiled WASM binaries — they are a supply chain risk.
|
|
//! This script builds telegram.wasm from channels-src/telegram before the main crate compiles.
|
|
//!
|
|
//! Reproducible build:
|
|
//! cargo build --release
|
|
//! (build.rs invokes the channel build automatically)
|
|
//!
|
|
//! Prerequisites: rustup target add wasm32-wasip2, cargo install wasm-tools
|
|
|
|
use std::env;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
fn main() {
|
|
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
|
let root = PathBuf::from(&manifest_dir);
|
|
|
|
// ── Git build metadata ─────────────────────────────────────────────
|
|
emit_git_metadata(&root);
|
|
|
|
// ── Embed registry manifests ────────────────────────────────────────
|
|
embed_registry_catalog(&root);
|
|
|
|
// ── Embed bundled skills ────────────────────────────────────────────
|
|
embed_skills(&root);
|
|
|
|
// ── Build Telegram channel WASM ─────────────────────────────────────
|
|
let channel_dir = root.join("channels-src/telegram");
|
|
let wasm_out = channel_dir.join("telegram.wasm");
|
|
|
|
// Rerun when channel source or build script changes
|
|
println!("cargo:rerun-if-changed=channels-src/telegram/src");
|
|
println!("cargo:rerun-if-changed=channels-src/telegram/Cargo.toml");
|
|
println!("cargo:rerun-if-changed=wit/channel.wit");
|
|
|
|
if !channel_dir.is_dir() {
|
|
return;
|
|
}
|
|
|
|
// Build WASM module
|
|
let status = match Command::new("cargo")
|
|
.args([
|
|
"build",
|
|
"--release",
|
|
"--target",
|
|
"wasm32-wasip2",
|
|
"--manifest-path",
|
|
channel_dir.join("Cargo.toml").to_str().unwrap(),
|
|
])
|
|
.current_dir(&root)
|
|
.status()
|
|
{
|
|
Ok(s) => s,
|
|
Err(_) => {
|
|
eprintln!(
|
|
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if !status.success() {
|
|
eprintln!(
|
|
"cargo:warning=Telegram channel build failed. Run: ./channels-src/telegram/build.sh"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let raw_wasm = channel_dir.join("target/wasm32-wasip2/release/telegram_channel.wasm");
|
|
if !raw_wasm.exists() {
|
|
eprintln!(
|
|
"cargo:warning=Telegram WASM output not found at {:?}",
|
|
raw_wasm
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Convert to component and strip (wasm-tools)
|
|
let component_ok = Command::new("wasm-tools")
|
|
.args([
|
|
"component",
|
|
"new",
|
|
raw_wasm.to_str().unwrap(),
|
|
"-o",
|
|
wasm_out.to_str().unwrap(),
|
|
])
|
|
.current_dir(&root)
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
|
|
if !component_ok {
|
|
// Fallback: copy raw module if wasm-tools unavailable
|
|
if std::fs::copy(&raw_wasm, &wasm_out).is_err() {
|
|
eprintln!("cargo:warning=wasm-tools not found. Run: cargo install wasm-tools");
|
|
}
|
|
} else {
|
|
// Strip debug info (use temp file to avoid clobbering)
|
|
let stripped = wasm_out.with_extension("wasm.stripped");
|
|
let strip_ok = Command::new("wasm-tools")
|
|
.args([
|
|
"strip",
|
|
wasm_out.to_str().unwrap(),
|
|
"-o",
|
|
stripped.to_str().unwrap(),
|
|
])
|
|
.current_dir(&root)
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
if strip_ok {
|
|
let _ = std::fs::rename(&stripped, &wasm_out);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Emit `GIT_COMMIT_HASH` and `GIT_DIRTY` as compile-time env vars.
|
|
///
|
|
/// If the current HEAD is an exact tag match, `GIT_COMMIT_HASH` is empty
|
|
/// (the Cargo version is sufficient). Otherwise it contains the short hash,
|
|
/// and `GIT_DIRTY` is "true" if the working tree has uncommitted changes.
|
|
fn emit_git_metadata(root: &Path) {
|
|
// Rerun when the git HEAD changes (commit, checkout, rebase).
|
|
// Use `git rev-parse --git-dir` so this works inside git worktrees
|
|
// (where `.git` is a file pointing elsewhere, not a directory).
|
|
// Use `--git-common-dir` to find branch refs, which live in the
|
|
// shared common dir rather than the per-worktree git dir.
|
|
if let Ok(output) = Command::new("git")
|
|
.args(["rev-parse", "--git-dir"])
|
|
.current_dir(root)
|
|
.output()
|
|
&& output.status.success()
|
|
{
|
|
let git_dir = std::path::PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
|
|
let git_common_dir = Command::new("git")
|
|
.args(["rev-parse", "--git-common-dir"])
|
|
.current_dir(root)
|
|
.output()
|
|
.ok()
|
|
.filter(|o| o.status.success())
|
|
.map(|o| {
|
|
std::path::PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
})
|
|
.unwrap_or_else(|| git_dir.clone());
|
|
|
|
// Watch the per-worktree HEAD.
|
|
let git_head = git_dir.join("HEAD");
|
|
if git_head.exists() {
|
|
println!("cargo:rerun-if-changed={}", git_head.display());
|
|
// Also watch the ref that HEAD points to (for branch commits).
|
|
// In worktrees, branch refs (e.g. refs/heads/main) live under
|
|
// the common dir, not the per-worktree git dir.
|
|
if let Ok(head) = std::fs::read_to_string(&git_head)
|
|
&& let Some(refpath) = head.trim().strip_prefix("ref: ")
|
|
{
|
|
let reffile = git_common_dir.join(refpath);
|
|
if reffile.exists() {
|
|
println!("cargo:rerun-if-changed={}", reffile.display());
|
|
} else {
|
|
// Fallback for non-worktree repos where common == git dir.
|
|
let reffile_fallback = git_dir.join(refpath);
|
|
if reffile_fallback.exists() {
|
|
println!("cargo:rerun-if-changed={}", reffile_fallback.display());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if HEAD is an exact version tag (e.g. v0.25.0).
|
|
let is_tagged = Command::new("git")
|
|
.args(["describe", "--exact-match", "--tags", "HEAD"])
|
|
.current_dir(root)
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false);
|
|
|
|
if is_tagged {
|
|
// Tagged release — version string alone is enough.
|
|
println!("cargo:rustc-env=GIT_COMMIT_HASH=");
|
|
println!("cargo:rustc-env=GIT_DIRTY=false");
|
|
return;
|
|
}
|
|
|
|
// Short commit hash.
|
|
let hash = Command::new("git")
|
|
.args(["rev-parse", "--short", "HEAD"])
|
|
.current_dir(root)
|
|
.output()
|
|
.ok()
|
|
.and_then(|o| {
|
|
if o.status.success() {
|
|
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
// Dirty flag.
|
|
let dirty = Command::new("git")
|
|
.args(["status", "--porcelain"])
|
|
.current_dir(root)
|
|
.output()
|
|
.map(|o| o.status.success() && !o.stdout.is_empty())
|
|
.unwrap_or(false);
|
|
|
|
println!("cargo:rustc-env=GIT_COMMIT_HASH={}", hash);
|
|
println!("cargo:rustc-env=GIT_DIRTY={}", dirty);
|
|
}
|
|
|
|
/// Collect all registry manifests into a single JSON blob at compile time.
|
|
///
|
|
/// Output: `$OUT_DIR/embedded_catalog.json` with structure:
|
|
/// ```json
|
|
/// { "tools": [...], "channels": [...], "bundles": {...} }
|
|
/// ```
|
|
fn embed_registry_catalog(root: &Path) {
|
|
use std::fs;
|
|
|
|
let registry_dir = root.join("registry");
|
|
|
|
// Directory-level watches ensure Cargo reruns build.rs when new files are
|
|
// added or removed. Per-file watches (emitted inside collect_json_files)
|
|
// cover content changes to existing files.
|
|
println!("cargo:rerun-if-changed=registry/_bundles.json");
|
|
println!("cargo:rerun-if-changed=registry/tools");
|
|
println!("cargo:rerun-if-changed=registry/channels");
|
|
println!("cargo:rerun-if-changed=registry/mcp-servers");
|
|
|
|
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // safety: build script
|
|
let out_path = out_dir.join("embedded_catalog.json");
|
|
|
|
if !registry_dir.is_dir() {
|
|
// No registry dir: write empty catalog
|
|
fs::write(
|
|
&out_path,
|
|
r#"{"tools":[],"channels":[],"mcp_servers":[],"bundles":{"bundles":{}}}"#,
|
|
)
|
|
.unwrap();
|
|
return;
|
|
}
|
|
|
|
let mut tools = Vec::new();
|
|
let mut channels = Vec::new();
|
|
let mut mcp_servers = Vec::new();
|
|
|
|
// Collect tool manifests
|
|
let tools_dir = registry_dir.join("tools");
|
|
if tools_dir.is_dir() {
|
|
collect_json_files(&tools_dir, &mut tools);
|
|
}
|
|
|
|
// Collect channel manifests
|
|
let channels_dir = registry_dir.join("channels");
|
|
if channels_dir.is_dir() {
|
|
collect_json_files(&channels_dir, &mut channels);
|
|
}
|
|
|
|
// Collect MCP server manifests
|
|
let mcp_servers_dir = registry_dir.join("mcp-servers");
|
|
if mcp_servers_dir.is_dir() {
|
|
collect_json_files(&mcp_servers_dir, &mut mcp_servers);
|
|
}
|
|
|
|
// Read bundles
|
|
let bundles_path = registry_dir.join("_bundles.json");
|
|
let bundles_raw = if bundles_path.is_file() {
|
|
fs::read_to_string(&bundles_path).unwrap_or_else(|_| r#"{"bundles":{}}"#.to_string())
|
|
} else {
|
|
r#"{"bundles":{}}"#.to_string()
|
|
};
|
|
|
|
// Build the combined JSON
|
|
let catalog = format!(
|
|
r#"{{"tools":[{}],"channels":[{}],"mcp_servers":[{}],"bundles":{}}}"#,
|
|
tools.join(","),
|
|
channels.join(","),
|
|
mcp_servers.join(","),
|
|
bundles_raw,
|
|
);
|
|
|
|
fs::write(&out_path, catalog).unwrap(); // safety: build script
|
|
}
|
|
|
|
/// Collect all `skills/*/SKILL.md` files into an embedded JSON blob.
|
|
///
|
|
/// Output: `$OUT_DIR/embedded_skills.json` — a JSON array of `{"name": "...", "content": "..."}`.
|
|
/// These are loaded at runtime as bundled skills (lowest discovery priority, Trusted trust level).
|
|
fn embed_skills(root: &Path) {
|
|
use std::fs;
|
|
|
|
let skills_dir = root.join("skills");
|
|
|
|
// Rerun when any skill changes
|
|
println!("cargo:rerun-if-changed=skills");
|
|
|
|
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // safety: build script panics on failure
|
|
let out_path = out_dir.join("embedded_skills.json");
|
|
|
|
if !skills_dir.is_dir() {
|
|
fs::write(&out_path, "[]").unwrap(); // safety: build script
|
|
return;
|
|
}
|
|
|
|
let mut skills: Vec<String> = Vec::new();
|
|
|
|
let mut entries: Vec<_> = fs::read_dir(&skills_dir)
|
|
.unwrap() // safety: build script
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.path().is_dir())
|
|
.collect();
|
|
entries.sort_by_key(|e| e.file_name());
|
|
|
|
for entry in entries {
|
|
let skill_md = entry.path().join("SKILL.md");
|
|
if !skill_md.is_file() {
|
|
continue;
|
|
}
|
|
// Emit per-file watch
|
|
println!("cargo:rerun-if-changed={}", skill_md.display());
|
|
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
if let Ok(content) = fs::read_to_string(&skill_md) {
|
|
// Escape for JSON embedding
|
|
let name_json = serde_json::to_string(&name).unwrap(); // safety: build script
|
|
let content_json = serde_json::to_string(&content).unwrap(); // safety: build script
|
|
skills.push(format!(
|
|
r#"{{"name":{},"content":{}}}"#,
|
|
name_json, content_json
|
|
));
|
|
}
|
|
}
|
|
|
|
let catalog = format!("[{}]", skills.join(","));
|
|
fs::write(&out_path, catalog).unwrap(); // safety: build script
|
|
}
|
|
|
|
/// Read all .json files from a directory and push their raw contents into `out`.
|
|
fn collect_json_files(dir: &Path, out: &mut Vec<String>) {
|
|
use std::fs;
|
|
|
|
let mut entries: Vec<_> = fs::read_dir(dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("json")
|
|
})
|
|
.collect();
|
|
|
|
// Sort for deterministic output
|
|
entries.sort_by_key(|e| e.file_name());
|
|
|
|
for entry in entries {
|
|
// Emit per-file watch so Cargo reruns when file contents change
|
|
println!("cargo:rerun-if-changed={}", entry.path().display());
|
|
if let Ok(content) = fs::read_to_string(entry.path()) {
|
|
out.push(content);
|
|
}
|
|
}
|
|
}
|