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

Filter by extension

Filter by extension


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

on:
push:
tags:
- "v*"

permissions:
contents: write

env:
RUSTFLAGS: "-D warnings"

jobs:
check:
name: CI checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- uses: Swatinem/rust-cache@v2

- name: Rust checks
run: cargo fmt --check && cargo clippy -- -D warnings && cargo build && cargo test

build:
name: Build ${{ matrix.target }}
needs: check
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-latest

steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}

- uses: Swatinem/rust-cache@v2

- name: Build release binary
run: cargo build --release --target ${{ matrix.target }}

- name: Package artifact
shell: bash
run: |
tag="${GITHUB_REF#refs/tags/}"
name="bugatti-${tag}-${{ matrix.target }}"
mkdir -p "dist/${name}"
cp "target/${{ matrix.target }}/release/bugatti" "dist/${name}/"
cd dist
tar czf "${name}.tar.gz" "${name}"

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: bugatti-${{ matrix.target }}
path: dist/*.tar.gz

release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true

- name: Generate checksums
run: |
cd artifacts
sha256sum *.tar.gz > checksums-sha256.txt

- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "${tag}" \
--title "${tag}" \
--generate-notes \
--draft \
artifacts/*.tar.gz \
artifacts/checksums-sha256.txt
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bugatti"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
description = "A CLI for plain-English, agent-assisted local application verification using *.test.toml files"

Expand Down
86 changes: 61 additions & 25 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/bin/sh
# install.sh — Bugatti CLI installer.
#
# Builds from source (requires Rust/Cargo) and installs the binary.
# When run via curl|sh, downloads the pre-built binary from GitHub Releases.
# When run from a repo checkout, builds from source (requires Rust/Cargo).
#
# Usage:
# curl -sSf https://raw.githubusercontent.com/codesoda/bugatti-cli/main/install.sh | sh
# ./install.sh [options] # from a repo checkout
# ./install.sh [options] # from a repo checkout (builds from source)
#
# Options:
# --skip-symlink Skip creating ~/.local/bin symlink
Expand Down Expand Up @@ -142,41 +143,64 @@ trap cleanup EXIT INT TERM
INSTALLED_BINARY=""
SOURCE_ROOT=""

# --- Resolve source tree ---
# --- Detect architecture ---

resolve_source_root() {
# If invoked from a repo checkout, use it directly
script_dir="$(cd "$(dirname "$0")" && pwd)"
if [ -f "$script_dir/Cargo.toml" ] && [ -d "$script_dir/src" ]; then
SOURCE_ROOT="$script_dir"
return 0
fi
detect_target() {
os="$(uname -s)"
arch="$(uname -m)"

case "$os" in
Darwin) ;;
*) die "Pre-built binaries are only available for macOS (got $os). Build from a clone instead." ;;
esac

# Download source archive
case "$arch" in
arm64|aarch64) echo "aarch64-apple-darwin" ;;
*) die "Pre-built binaries are only available for arm64 (got $arch). Build from a clone instead." ;;
esac
}

# --- Install from GitHub release ---

install_from_release() {
if ! command -v curl >/dev/null 2>&1; then
die "curl is required for remote install"
fi

info "Downloading source from GitHub..."
TMP_DIR="$(mktemp -d)"
archive_url="https://github.com/$REPO_OWNER/$REPO_NAME/archive/refs/heads/$REPO_REF.tar.gz"
target="$(detect_target)"

if ! curl -sSL "$archive_url" | tar xz -C "$TMP_DIR" 2>/dev/null; then
die "Failed to download source from $archive_url"
header "Fetching latest release"

# Get the latest release tag
latest_url="https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
tag="$(curl -sSf "$latest_url" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')"
if [ -z "$tag" ]; then
die "Could not determine latest release — check https://github.com/$REPO_OWNER/$REPO_NAME/releases"
fi
ok_detail "Release" "$tag"

asset_name="bugatti-${tag}-${target}.tar.gz"
asset_url="https://github.com/$REPO_OWNER/$REPO_NAME/releases/download/${tag}/${asset_name}"

TMP_DIR="$(mktemp -d)"
info "Downloading $asset_name..."
if ! curl -sSfL "$asset_url" -o "$TMP_DIR/$asset_name"; then
die "Failed to download $asset_url"
fi
ok "Downloaded"

extracted="$TMP_DIR/$REPO_NAME-$REPO_REF"
if [ ! -f "$extracted/Cargo.toml" ]; then
die "Downloaded archive does not contain expected source tree"
tar xzf "$TMP_DIR/$asset_name" -C "$TMP_DIR"
downloaded_binary="$TMP_DIR/bugatti-${tag}-${target}/bugatti"
if [ ! -f "$downloaded_binary" ]; then
die "Archive does not contain expected binary"
fi

SOURCE_ROOT="$extracted"
install_binary "$downloaded_binary"
}

# --- Build from source ---
# --- Build from local source ---

build_from_source() {
resolve_source_root
ok_detail "Source tree" "$SOURCE_ROOT"

header "Checking prerequisites"
Expand All @@ -196,8 +220,13 @@ build_from_source() {
fi

ok_detail "Built" "$built_binary"
install_binary "$built_binary"
}

# Install to ~/.<home>/bin — use symlink for local checkouts, copy otherwise
# --- Install binary to BUGATTI_HOME ---

install_binary() {
src_binary="$1"
bugatti_home="${BUGATTI_HOME:-$HOME/.bugatti}"
bin_dir="$bugatti_home/bin"
mkdir -p "$bin_dir"
Expand All @@ -209,7 +238,7 @@ build_from_source() {
rm "$target_path"
fi

cp "$built_binary" "$target_path"
cp "$src_binary" "$target_path"
chmod +x "$target_path"
ok_detail "Installed" "$target_path"

Expand Down Expand Up @@ -277,7 +306,14 @@ main() {
dim "━━━━━━━━━━━━━━━━━"
printf '\n'

build_from_source
# If running from a repo checkout, build locally; otherwise grab the release binary
script_dir="$(cd "$(dirname "$0")" && pwd)"
if [ -f "$script_dir/Cargo.toml" ] && [ -d "$script_dir/src" ]; then
SOURCE_ROOT="$script_dir"
build_from_source
else
install_from_release
fi

if [ "$SKIP_SYMLINK" = 0 ]; then
ensure_local_bin_symlink || true
Expand Down
76 changes: 55 additions & 21 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ fn execute_short_lived(

if !output.status.success() {
tracing::error!(command = name, exit_code = ?exit_code, "short-lived command failed");
// Print last lines of output so the user can see what went wrong
print_output_tail("stderr", &output.stderr);
print_output_tail("stdout", &output.stdout);
return Err(CommandError::NonZeroExit {
name: name.to_string(),
exit_code,
Expand All @@ -212,6 +215,20 @@ fn execute_short_lived(
})
}

/// Print the last non-empty lines of command output, prefixed with a label.
fn print_output_tail(label: &str, output: &[u8]) {
let text = String::from_utf8_lossy(output);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
if lines.is_empty() {
return;
}
let tail: Vec<&str> = lines.into_iter().rev().take(10).collect::<Vec<_>>().into_iter().rev().collect();
println!(" {label}:");
for line in tail {
println!(" {line}");
}
}

/// Default timeout for readiness checks (30 seconds).
const DEFAULT_READINESS_TIMEOUT: Duration = Duration::from_secs(30);

Expand Down Expand Up @@ -266,19 +283,23 @@ pub fn spawn_long_lived_commands(
if skip_cmds.contains(name) {
println!("SKIP ....... {name} (long-lived)");
// Readiness checks still run for skipped commands unless explicitly disabled
if let Some(ref readiness_url) = def.readiness_url {
let urls = def.effective_readiness_urls();
if !urls.is_empty() {
if skip_readiness.contains(name) {
println!("SKIP ....... {name} readiness check (--skip-readiness)");
} else {
println!("WAIT ....... {name} (skipped): polling {readiness_url}");
if let Err(e) = poll_readiness(readiness_url, DEFAULT_READINESS_TIMEOUT) {
println!("FAIL ....... {name} (skipped): readiness check failed");
teardown_processes(&mut tracked);
return Err(CommandError::ReadinessFailed {
name: name.to_string(),
url: readiness_url.clone(),
message: e,
});
let timeout = readiness_timeout(def);
for url in &urls {
println!("WAIT ....... {name} (skipped): polling {url}");
if let Err(e) = poll_readiness(url, timeout) {
println!("FAIL ....... {name} (skipped): readiness check failed");
teardown_processes(&mut tracked);
return Err(CommandError::ReadinessFailed {
name: name.to_string(),
url: url.to_string(),
message: e,
});
}
}
println!("READY ...... {name} (skipped)");
}
Expand Down Expand Up @@ -325,17 +346,21 @@ pub fn spawn_long_lived_commands(
tracked.push(process);

// Check readiness if configured
if let Some(ref readiness_url) = def.readiness_url {
println!("WAIT ....... {name}: polling {readiness_url}");
if let Err(e) = poll_readiness(readiness_url, DEFAULT_READINESS_TIMEOUT) {
// Readiness failed - tear down what we've started
println!("FAIL ....... {name}: readiness check failed");
teardown_processes(&mut tracked);
return Err(CommandError::ReadinessFailed {
name: name.to_string(),
url: readiness_url.clone(),
message: e,
});
let urls = def.effective_readiness_urls();
if !urls.is_empty() {
let timeout = readiness_timeout(def);
for url in &urls {
println!("WAIT ....... {name}: polling {url}");
if let Err(e) = poll_readiness(url, timeout) {
// Readiness failed - tear down what we've started
println!("FAIL ....... {name}: readiness check failed");
teardown_processes(&mut tracked);
return Err(CommandError::ReadinessFailed {
name: name.to_string(),
url: url.to_string(),
message: e,
});
}
}
println!("READY ...... {name}");
}
Expand All @@ -344,6 +369,13 @@ pub fn spawn_long_lived_commands(
Ok(tracked)
}

/// Compute the readiness timeout for a command, using the per-command override or the default.
fn readiness_timeout(def: &CommandDef) -> Duration {
def.readiness_timeout_secs
.map(Duration::from_secs)
.unwrap_or(DEFAULT_READINESS_TIMEOUT)
}

/// Poll a readiness URL until it responds with a success status or timeout.
fn poll_readiness(url: &str, timeout: Duration) -> Result<(), String> {
tracing::info!(
Expand Down Expand Up @@ -518,6 +550,8 @@ mod tests {
kind,
cmd: cmd.to_string(),
readiness_url: readiness_url.map(String::from),
readiness_urls: Vec::new(),
readiness_timeout_secs: None,
},
);
}
Expand Down
Loading