Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions README_ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ foc-devnet init [OPTIONS]
- `--rand` - Use random mnemonic instead of deterministic one. Use this for unique test scenarios.

**Source Format:**
- `latesttag:<branch>` - Newest git tag found on the given branch (resolved once at `init` time and pinned in config). Use this to always pull the latest stable release on a branch without hardcoding a version. Example: `latesttag:master`
- `gittag:v1.0.0` - Specific git tag (uses default repo)
- `gittag:https://github.com/user/repo.git:v1.0.0` - Tag from custom repo
- `gitcommit:abc123` - Specific git commit
Expand Down Expand Up @@ -812,6 +813,7 @@ port_range_count = 100
Default versions for these repositories are defined in code (see [`src/config.rs`](src/config.rs) `Config::default()`).

**Version specification methods:**
- **Latest tag** (`LatestTag`, i.e. `latesttag:<branch>`): Resolves to the newest git tag on the given branch at `init` time and pins that exact tag in `config.toml`. Use this to automatically track the latest stable release without hardcoding a version.
- **Git tags** (`GitTag`): Used for stable releases. Tags provide version pinning and stability.
- **Git commits** (`GitCommit`): Used for repositories where specific commits are required and there isn't a corresponding tag yet. (Generally tags should be preferred over commits.)
- **Git branches** (`GitBranch`): Used for development or when tracking latest changes.
Expand Down
22 changes: 17 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,25 @@ pub enum Commands {
Stop,
/// Initialize foc-devnet by building and caching Docker images
Init {
/// Curio source location (e.g., 'gittag:tag', 'gittag:url:tag', 'gitcommit:commit', 'gitcommit:url:commit', 'gitbranch:branch', 'gitbranch:url:branch', 'local:/path/to/curio')
/// Curio source location.
/// Magic values: 'latesttag:<branch>' (newest tag on given branch).
/// Explicit: 'gittag:<tag>', 'gittag:<url>:<tag>', 'gitcommit:<sha>',
/// 'gitcommit:<url>:<sha>', 'gitbranch:<branch>', 'gitbranch:<url>:<branch>',
/// 'local:/path/to/curio'.
#[arg(long)]
curio: Option<String>,
/// Lotus source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/lotus')
/// Lotus source location.
/// Magic values: 'latesttag:<branch>' (newest tag on given branch).
/// Explicit: 'gittag:<tag>', 'gittag:<url>:<tag>', 'gitcommit:<sha>',
/// 'gitcommit:<url>:<sha>', 'gitbranch:<branch>', 'gitbranch:<url>:<branch>',
/// 'local:/path/to/lotus'.
#[arg(long)]
lotus: Option<String>,
/// Filecoin Services source location (e.g., 'gittag:v1.0.0', 'gittag:url:tag', 'gitcommit:abc123', 'gitcommit:url:commit', 'gitbranch:main', 'gitbranch:url:main', 'local:/path/to/filecoin-services')
/// Filecoin Services source location.
/// Magic values: 'latesttag:<branch>' (newest tag on given branch).
/// Explicit: 'gittag:<tag>', 'gittag:<url>:<tag>', 'gitcommit:<sha>',
/// 'gitcommit:<url>:<sha>', 'gitbranch:<branch>', 'gitbranch:<url>:<branch>',
/// 'local:/path/to/filecoin-services'.
#[arg(long)]
filecoin_services: Option<String>,
/// Yugabyte download URL
Expand Down Expand Up @@ -82,12 +94,12 @@ pub enum BuildCommands {
pub enum ConfigCommands {
/// Configure Lotus source location
Lotus {
/// Lotus source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/lotus')
/// Lotus source location (e.g., 'latesttag:master', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/lotus')
source: String,
},
/// Configure Curio source location
Curio {
/// Curio source location (e.g., 'gittag:v1.0.0', 'gitcommit:abc123', 'local:/path/to/curio')
/// Curio source location (e.g., 'latesttag:pdpv0', 'gittag:v1.0.0', 'gitcommit:abc123', 'gitbranch:main', 'local:/path/to/curio')
source: String,
},
}
3 changes: 3 additions & 0 deletions src/commands/build/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@
}
Location::GitBranch { url, branch } => {
prepare_git_repo(&repo_path, url)?;
checkout_branch(&repo_path, branch)?;

Check warning on line 51 in src/commands/build/repository.rs

View workflow job for this annotation

GitHub Actions / fmt-clippy

Diff in /home/runner/work/foc-devnet/foc-devnet/src/commands/build/repository.rs
}
Location::LatestTag { .. } => {
return Err(format!("{}: LatestTag should have been resolved at init time", project).into());
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
}
}

info!("Repository prepared successfully");
Expand Down
13 changes: 12 additions & 1 deletion src/commands/init/config.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
//! Configuration generation utilities for foc-devnet initialization.
//!
//! This module handles the generation of default configuration files
//! and application of location overrides.
//! and application of location overrides. Dynamic location variants
//! (`LatestTag`) is resolved to a concrete value at init
//! time via [`super::latest_resolver`], ensuring the stored config always
//! records the exact tag that was used.

use std::fs;
use tracing::{info, warn};

use super::latest_resolver::resolve_location;
use crate::config::{Config, Location};
use crate::paths::foc_devnet_config;

Expand Down Expand Up @@ -67,6 +71,12 @@ pub fn generate_default_config(
"https://github.com/FilOzone/filecoin-services.git",
)?;

// Resolve any dynamic variants (LatestTag) by querying the remote.
// The resolved concrete tag is stored in config.toml for reproducibility.
config.lotus = resolve_location(config.lotus)?;
config.curio = resolve_location(config.curio)?;
config.filecoin_services = resolve_location(config.filecoin_services)?;
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LatestTag resolution only runs when generating a new config. If a config already exists (or is updated via foc-devnet config ...), LatestTag can remain in config.toml, but later init/build paths error out with “should have been resolved at init time”. Consider resolving LatestTag when loading config (or when applying overrides / updating config) and writing back the resolved GitTag for reproducibility.

Copilot uses AI. Check for mistakes.
Comment thread
redpanda-f marked this conversation as resolved.
Outdated

// Override yugabyte URL if provided
if let Some(url) = yugabyte_url {
config.yugabyte_download_url = url;
Expand Down Expand Up @@ -103,6 +113,7 @@ pub fn apply_location_override(
Location::GitTag { ref url, .. } => url.clone(),
Location::GitCommit { ref url, .. } => url.clone(),
Location::GitBranch { ref url, .. } => url.clone(),
Location::LatestTag { ref url, .. } => url.clone(),
Location::LocalSource { .. } => default_url.to_string(),
};
*location = Location::parse_with_default(&loc_str, &url)
Expand Down
116 changes: 116 additions & 0 deletions src/commands/init/latest_resolver.rs
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Resolves `LatestTag` → `GitTag` at init time.
//!
//! When a user specifies `--lotus latesttag:master` (or similar), we need to
//! figure out which concrete tag that maps to. This module:
//!
//! 1. Creates a temporary bare git repo (no working tree, no blobs).
//! 2. Fetches only the requested branch + tags from the remote.
//! 3. Picks the newest tag reachable from that branch.
//! 4. Returns a `GitTag` so the rest of the system works with a pinned version.
//!
//! The temp repo is automatically cleaned up when it goes out of scope.

use crate::config::Location;
use std::process::Command;
use tracing::info;

/// Temporary bare git repo that deletes itself on drop.
///
/// We use a bare repo (no checkout) so we never download actual file content —
/// only refs and tag metadata. The `--filter=blob:none` fetch flag ensures
/// this stays lightweight even for large repositories.
struct TempBareRepo(std::path::PathBuf);
Comment thread
redpanda-f marked this conversation as resolved.
Outdated

impl TempBareRepo {
fn create() -> Result<Self, Box<dyn std::error::Error>> {
let dir = std::env::temp_dir().join(format!(
"foc-devnet-tag-probe-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let status = Command::new("git")
.args(["init", "--bare", dir.to_str().unwrap()])
.env("GIT_TERMINAL_PROMPT", "0")
.status()?;
if !status.success() {
return Err("git init --bare failed".into());
}
Ok(Self(dir))
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
}

fn path(&self) -> &std::path::Path {
&self.0
}
}

impl Drop for TempBareRepo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}

/// If `location` is `LatestTag`, resolve it to a concrete `GitTag`.
/// All other variants pass through unchanged.
///
/// Example: `LatestTag { url: "…lotus.git", branch: "master" }`
/// → `GitTag { url: "…lotus.git", tag: "v1.35.0" }`
pub fn resolve_location(location: Location) -> Result<Location, Box<dyn std::error::Error>> {
match location {
Location::LatestTag { url, branch } => {
let tag = fetch_latest_tag(&url, &branch)?;
info!("Resolved latesttag: {} (branch {}) → {}", url, branch, tag);
Ok(Location::GitTag { url, tag })
}
other => Ok(other),
}
}

/// Fetch the newest tag on `branch` from the remote at `url`.
///
/// Steps:
/// 1. `git fetch --tags --filter=blob:none <url> refs/heads/<branch>`
/// — pulls the branch ref and all tags without downloading any file blobs.
/// 2. `git tag --merged <branch> --sort=-creatordate`
/// — lists tags reachable from that branch, newest first.
/// 3. Take the first line → that's the latest tag.
fn fetch_latest_tag(url: &str, branch: &str) -> Result<String, Box<dyn std::error::Error>> {
info!("Fetching newest tag on branch '{}' from {}", branch, url);

let repo = TempBareRepo::create()?;
let refspec = format!("refs/heads/{b}:refs/heads/{b}", b = branch);

// Fetch branch + tags (no blobs — keeps it fast)
let fetch = Command::new("git")
.args(["fetch", "--tags", "--filter=blob:none", url, &refspec])
.current_dir(repo.path())
.env("GIT_TERMINAL_PROMPT", "0")
.status()?;
if !fetch.success() {
return Err(format!("git fetch failed for {} (branch {})", url, branch).into());
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
}

// List tags reachable from the branch, newest first
let tags = Command::new("git")
.args(["tag", "--merged", branch, "--sort=-creatordate"])
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
.current_dir(repo.path())
.output()?;
if !tags.status.success() {
return Err(format!(
"git tag --merged {} failed: {}",
branch,
String::from_utf8_lossy(&tags.stderr).trim()
)
.into());
}

// First non-empty line is the newest tag
let stdout = String::from_utf8_lossy(&tags.stdout);
stdout
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
.ok_or_else(|| format!("No tags found on branch '{}' for {}", branch, url).into())
}
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
1 change: 1 addition & 0 deletions src/commands/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod artifacts;
pub mod config;
pub mod directories;
pub mod keys;
pub mod latest_resolver;
pub mod path_setup;
pub mod repositories;

Expand Down
3 changes: 3 additions & 0 deletions src/commands/init/repositories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ fn download_repository(name: &str, location: &Location) -> Result<(), Box<dyn st
Location::GitBranch { url, branch } => {
clone_and_checkout(name, url, None, None, Some(branch))
}
Location::LatestTag { .. } => {
Err(format!("{}: LatestTag should have been resolved at init time", name).into())
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/commands/status/git/formatters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ pub fn format_location_info(
) if expected_branch == actual_branch => true,
(Location::GitBranch { .. }, GitInfo::Tag(_) | GitInfo::Commit(_)) => true, // Assume it's ready if we have some valid state
(Location::GitBranch { .. }, _) => false,

// LatestTag is resolved at init time; treat as not ready if somehow present.
(Location::LatestTag { .. }, _) => false,
};
Comment thread
redpanda-f marked this conversation as resolved.
Outdated

let status = if is_ready {
Expand Down Expand Up @@ -125,6 +128,12 @@ pub fn format_location_info(
"Not found".to_string(),
),
},
// Resolved at init time; display as underlying type if somehow still present.
Location::LatestTag { .. } => (
"Latest Tag".to_string(),
"(unresolved)".to_string(),
"".to_string(),
),
};

(source_type, version, commit, status)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/status/git/repo_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
pub fn get_repo_path_from_config(location: &Location, component: &str) -> std::path::PathBuf {
match location {
Location::LocalSource { dir } => {
// For local sources, check the specified directory

Check warning on line 34 in src/commands/status/git/repo_paths.rs

View workflow job for this annotation

GitHub Actions / fmt-clippy

Diff in /home/runner/work/foc-devnet/foc-devnet/src/commands/status/git/repo_paths.rs
std::path::PathBuf::from(dir)
}
Location::GitTag { .. } | Location::GitCommit { .. } | Location::GitBranch { .. } => {
Location::GitTag { .. } | Location::GitCommit { .. } | Location::GitBranch { .. } | Location::LatestTag { .. } => {
// For git sources, check if it exists in the foc-devnet code directory
foc_devnet_code().join(component)
}
Expand Down
38 changes: 26 additions & 12 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,35 @@ pub enum Location {
/// The `url` field is the Git repository URL, and `branch` is the specific
/// branch (e.g., "main", "develop") to check out.
GitBranch { url: String, branch: String },

/// Resolve to the newest tag on a specific branch at init time.
///
/// `url` is the Git repository URL. `branch` specifies the exact branch
/// to search for tags on. At init time this is immediately resolved to a
/// concrete `GitTag` so the stored config always records the exact tag used.
///
/// Example CLI usage: `--curio latesttag:pdpv0` or `--lotus latesttag:master`
LatestTag { url: String, branch: String },
}

impl Location {
/// Parse a location string in the format "type:value" or "type:url:value"
/// Parse a location string in the format "type" or "type:value".
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
///
/// Supported formats:
/// - "gittag:tag" (uses default URL)
/// - "gitcommit:commit" (uses default URL)
/// - "gitbranch:branch" (uses default URL)
/// - "local:dir"
/// - "gittag:url:tag"
/// - "gitcommit:url:commit"
/// - "gitbranch:url:branch"
///
/// Where url can contain colons (e.g., https://github.com/repo.git)
/// - `latesttag:<branch>` — newest tag on specified branch (e.g. `latesttag:main`)
/// - `gittag:<tag>` — (uses default URL)
/// - `gitcommit:<commit>` — (uses default URL)
/// - `gitbranch:<branch>` — (uses default URL)
/// - `local:<dir>`
/// - `gittag:<url>:<tag>`
/// - `gitcommit:<url>:<commit>`
/// - `gitbranch:<url>:<branch>`
pub fn parse_with_default(s: &str, default_url: &str) -> Result<Self, String> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() < 2 {
return Err(format!(
"Invalid location format: {}. Expected 'type:value' or 'type:url:value'",
"Invalid location format: '{}'. Expected \
'latesttag:<branch>', or 'gittag/gitcommit/gitbranch/local:...'",
s
));
}
Expand All @@ -64,6 +73,11 @@ impl Location {
let remaining = &parts[1..].join(":");

match location_type {
// latesttag:<branch> — newest tag on specified branch
"latesttag" => Ok(Location::LatestTag {
url: default_url.to_string(),
branch: remaining.to_string(),
}),
Comment thread
redpanda-f marked this conversation as resolved.
Outdated
"local" => Ok(Location::LocalSource {
dir: remaining.to_string(),
}),
Expand Down Expand Up @@ -107,7 +121,7 @@ impl Location {
}
}
_ => Err(format!(
"Unknown location type: {}. Supported types: local, gittag, gitcommit, gitbranch",
"Unknown location type: {}. Supported types: latesttag, local, gittag, gitcommit, gitbranch",
location_type
)),
}
Expand Down
3 changes: 3 additions & 0 deletions src/main_app/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@ fn print_location_info(label: &str, location: &Location) {
Location::GitBranch { url, branch } => {
info!("{}: {}, branch {}", label, url, branch);
}
Location::LatestTag { url, .. } => {
info!("{}: {}, latest tag (unresolved)", label, url);
}
}
}
Loading