diff --git a/.github/ci-groups.yml b/.github/ci-groups.yml index 288e4703b..cfd026315 100644 --- a/.github/ci-groups.yml +++ b/.github/ci-groups.yml @@ -29,3 +29,4 @@ excluded: - integration_test # Requires live Dash node - dash-fuzz # Honggfuzz binary targets, tested by fuzz.yml - masternode-seeds-fetcher # Tooling binary; needs live dashd RPC, exercised by update-masternode-seeds.yml + - git-state # Compile-time proc-macro with no tests, exercised via dash-spv diff --git a/Cargo.toml b/Cargo.toml index 5491e2f88..e8c6030df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["dash", "dash-network", "hashes", "internals", "fuzz", "rpc-client", "rpc-json", "rpc-integration-test", "key-wallet", "key-wallet-manager", "key-wallet-ffi", "dash-spv", "dash-spv-ffi", "dash-network-seeds", "masternode-seeds-fetcher"] +members = ["dash", "dash-network", "hashes", "internals", "fuzz", "rpc-client", "rpc-json", "rpc-integration-test", "key-wallet", "key-wallet-manager", "key-wallet-ffi", "dash-spv", "dash-spv-ffi", "git-state", "dash-network-seeds", "masternode-seeds-fetcher"] resolver = "2" [workspace.package] diff --git a/dash-spv/Cargo.toml b/dash-spv/Cargo.toml index 34955f88b..0c80f5b36 100644 --- a/dash-spv/Cargo.toml +++ b/dash-spv/Cargo.toml @@ -15,6 +15,7 @@ dashcore_hashes = { path = "../hashes" } dash-network-seeds = { path = "../dash-network-seeds" } key-wallet = { path = "../key-wallet" } key-wallet-manager = { path = "../key-wallet-manager", features = ["parallel-filters"] } +git-state = { path = "../git-state" } # CLI clap = { version = "4.0", features = ["derive", "env"] } diff --git a/dash-spv/build.rs b/dash-spv/build.rs new file mode 100644 index 000000000..464e7d330 --- /dev/null +++ b/dash-spv/build.rs @@ -0,0 +1,41 @@ +use std::process::Command; + +fn git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if text.is_empty() { + None + } else { + Some(text) + } +} + +fn main() { + let hash = git(&["rev-parse", "--short=12", "HEAD"]).unwrap_or_default(); + let tagged = git(&["describe", "--exact-match", "--tags", "--match", "v*", "HEAD"]).is_some(); + + println!("cargo:rustc-env=DASH_SPV_GIT_HASH={hash}"); + println!("cargo:rustc-env=DASH_SPV_GIT_TAGGED={tagged}"); + + println!("cargo:rerun-if-changed=build.rs"); + // Watching these git files keeps the hash current, and also keeps the + // `git_dirty!` macro correct on commit: a commit moves a watched ref, which + // reruns this script and forces the crate (and the macro) to recompile. + // Narrowing this set would let the dirty flag go stale across a commit. + // + // `.git/HEAD` only changes when switching branches, not when committing on + // the current one, so also watch the symbolic target's ref file. + if let Some(head_ref) = git(&["symbolic-ref", "--quiet", "HEAD"]) { + if let Some(p) = git(&["rev-parse", "--git-path", &head_ref]) { + println!("cargo:rerun-if-changed={p}"); + } + } + for path in ["HEAD", "index", "packed-refs"] { + if let Some(p) = git(&["rev-parse", "--git-path", path]) { + println!("cargo:rerun-if-changed={p}"); + } + } +} diff --git a/dash-spv/src/client/lifecycle.rs b/dash-spv/src/client/lifecycle.rs index 1e4125dc6..763ce5029 100644 --- a/dash-spv/src/client/lifecycle.rs +++ b/dash-spv/src/client/lifecycle.rs @@ -36,6 +36,8 @@ impl DashSpvClient>, event_handlers: Vec>, ) -> Result { + tracing::info!("{}", crate::version_info()); + // Validate configuration config.validate().map_err(SpvError::Config)?; diff --git a/dash-spv/src/lib.rs b/dash-spv/src/lib.rs index cabedf76a..f27c0a52c 100644 --- a/dash-spv/src/lib.rs +++ b/dash-spv/src/lib.rs @@ -101,3 +101,70 @@ pub use dashcore::sml::llmq_type::LLMQType; /// Current version of the dash-spv library. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Short git commit the library was built from, empty if it could not be determined. +pub const GIT_HASH: &str = env!("DASH_SPV_GIT_HASH"); + +/// Whether the source tree has uncommitted tracked changes. +/// +/// Resolved by [`git_state::git_dirty`] while this crate is compiled, so it +/// re-evaluates whenever the crate is rebuilt. An unstaged edit that triggers +/// a rebuild is reflected without staging or committing, which a build script +/// cannot achieve for the unbounded working tree. Builds with no git context +/// (e.g. a packaged source tarball) report `false`. +pub const GIT_DIRTY: bool = git_state::git_dirty!(); + +/// Whether the build was made from a commit pointed at by a `v*` release tag. +pub const GIT_TAGGED: bool = const_str_eq(env!("DASH_SPV_GIT_TAGGED"), "true"); + +const fn const_str_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +/// Human readable version. +/// +/// A release build (the commit is pointed at by a `v*` tag and the tree is clean) +/// renders just `dash-spv 0.42.0`. Any development build surfaces the commit so it +/// is recognizable as non-release: `dash-spv 0.42.0 (a1b2c3d4e5f6)`, or +/// `dash-spv 0.42.0 (a1b2c3d4e5f6-dirty)` with uncommitted changes. Builds with no +/// git context (e.g. a packaged source tarball) render just `dash-spv 0.42.0`. +pub fn version_info() -> String { + if GIT_HASH.is_empty() || (GIT_TAGGED && !GIT_DIRTY) { + format!("dash-spv {VERSION}") + } else if GIT_DIRTY { + format!("dash-spv {VERSION} ({GIT_HASH}-dirty)") + } else { + format!("dash-spv {VERSION} ({GIT_HASH})") + } +} + +#[cfg(test)] +mod tests { + use super::{version_info, GIT_DIRTY, GIT_HASH, GIT_TAGGED, VERSION}; + + #[test] + fn version_info_format() { + let info = version_info(); + assert!(info.starts_with("dash-spv ")); + assert!(info.contains(VERSION)); + + let is_release = GIT_HASH.is_empty() || (GIT_TAGGED && !GIT_DIRTY); + if is_release { + assert_eq!(info, format!("dash-spv {VERSION}")); + } else { + assert!(info.contains(GIT_HASH)); + assert_eq!(info.ends_with("-dirty)"), GIT_DIRTY); + } + } +} diff --git a/git-state/Cargo.toml b/git-state/Cargo.toml new file mode 100644 index 000000000..aaef86775 --- /dev/null +++ b/git-state/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "git-state" +version = { workspace = true } +edition = "2021" +authors = ["Dash Core Team"] +description = "Compile-time git repository state as procedural macros" +license = "MIT" +repository = "https://github.com/dashpay/rust-dashcore" +rust-version = "1.89" + +[lib] +proc-macro = true diff --git a/git-state/src/lib.rs b/git-state/src/lib.rs new file mode 100644 index 000000000..df911ba2c --- /dev/null +++ b/git-state/src/lib.rs @@ -0,0 +1,38 @@ +//! Compile-time git repository state, exposed as procedural macros. + +use proc_macro::TokenStream; +use std::env; +use std::process::Command; + +/// Expands to a `bool` literal: whether the repository containing the invoking +/// crate has uncommitted tracked changes, evaluated at compile time. +/// +/// Unlike a build-script environment variable, a macro is expanded as part of +/// compiling the invoking crate, so this re-evaluates whenever that crate is +/// recompiled. An unstaged edit that triggers a rebuild is therefore reflected +/// without staging or committing first, which a `rerun-if-changed` set on a +/// build script cannot capture for the unbounded working tree. +/// +/// Any failure to determine the state (git absent, not a repository, or a +/// packaged source tree with no `.git`) expands to `false` rather than guessing. +#[proc_macro] +pub fn git_dirty(_input: TokenStream) -> TokenStream { + let dirty = env::var("CARGO_MANIFEST_DIR") + .ok() + .and_then(|dir| { + Command::new("git") + .args(["-C", &dir, "status", "--porcelain", "--untracked-files=no"]) + .output() + .ok() + }) + .filter(|out| out.status.success()) + .map(|out| !out.stdout.is_empty()) + .unwrap_or(false); + + let literal = if dirty { + "true" + } else { + "false" + }; + literal.parse().expect("bool literal is valid tokens") +}