From bfcc111817b03c5f737374037eabef8eb6795e46 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 7 May 2026 16:59:46 -0700 Subject: [PATCH] fix: git sha for cli --- .github/workflows/publish.yml | 12 +++ flake.nix | 15 +++- packages/cli/build.rs | 149 +++++++++++++++++++++++++++++----- 3 files changed, 151 insertions(+), 25 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 951c9d523..1d98eef9d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -70,6 +70,18 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + # Build from the tag we're releasing, not the branch the workflow + # was dispatched from. Without this the binary's Cargo.toml version + # and embedded SHA reflect whatever was on the dispatch branch. + ref: refs/tags/${{ github.event.inputs.post }} + + - name: Resolve release commit SHA + shell: bash + run: | + sha=$(git rev-parse HEAD) + echo "DIOXUS_CLI_GIT_SHA=$sha" >> "$GITHUB_ENV" + echo "DIOXUS_CLI_GIT_SHA_SHORT=${sha:0:7}" >> "$GITHUB_ENV" - name: Install openssl on macos if: matrix.platform.os == 'macos-latest' diff --git a/flake.nix b/flake.nix index 6ac865568..14343b605 100644 --- a/flake.nix +++ b/flake.nix @@ -98,12 +98,19 @@ ); in { - packages.dioxus-cli = ( - rustPackage "dioxus-cli" { + packages.dioxus-cli = + (rustPackage "dioxus-cli" { binary = "dx"; features = [ "no-downloads" ]; - } - ); + }).overrideAttrs + (_: { + # The nix sandbox has no .git and no .cargo_vcs_info.json, so + # build.rs has nothing to derive the commit from. Pass the + # flake's own input rev through the env-var override path. + # `self.rev` is set on a clean tree; `dirtyRev` on a dirty one + # (and includes a `-dirty` suffix, which build.rs accepts). + DIOXUS_CLI_GIT_SHA = inputs.self.rev or inputs.self.dirtyRev or ""; + }); packages.default = self'.packages.dioxus-cli; checks.dioxus-cli = self'.packages.dioxus-cli; diff --git a/packages/cli/build.rs b/packages/cli/build.rs index 15b932f9e..5cf99ebea 100644 --- a/packages/cli/build.rs +++ b/packages/cli/build.rs @@ -1,38 +1,145 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; fn main() { println!("cargo:rerun-if-env-changed=DIOXUS_CLI_GIT_SHA"); println!("cargo:rerun-if-env-changed=DIOXUS_CLI_GIT_SHA_SHORT"); - println!("cargo:rerun-if-changed=.git/HEAD"); - println!("cargo:rerun-if-changed=.git/refs"); - let full_hash = std::env::var("DIOXUS_CLI_GIT_SHA").ok().or_else(|| { - let output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .ok()?; + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let git_dir = find_git_dir(&manifest_dir); - if !output.status.success() { - return None; + // Track the files that determine the current commit. Absolute paths are + // required because the build script runs from `packages/cli/`, not the + // workspace root where `.git` lives. + if let Some(git_dir) = git_dir.as_deref() { + emit_rerun(&git_dir.join("HEAD")); + emit_rerun(&git_dir.join("packed-refs")); + if let Some(target) = head_ref_target(git_dir) { + emit_rerun(&git_dir.join(target)); } + } + emit_rerun(&manifest_dir.join(".cargo_vcs_info.json")); - let hash = String::from_utf8(output.stdout).ok()?; - let hash = hash.trim().to_string(); - - if hash.is_empty() { - None - } else { - Some(hash) - } - }); + let full_hash = env_nonempty("DIOXUS_CLI_GIT_SHA") + .or_else(|| git_dir.as_deref().and_then(read_head_sha)) + .or_else(|| read_cargo_vcs_info(&manifest_dir)) + .or_else(|| git_cli_sha(&manifest_dir)); if let Some(full_hash) = full_hash { + let full_hash = full_hash.trim(); println!("cargo:rustc-env=DIOXUS_CLI_GIT_SHA={full_hash}"); - let short_hash = std::env::var("DIOXUS_CLI_GIT_SHA_SHORT") - .ok() + let short_hash = env_nonempty("DIOXUS_CLI_GIT_SHA_SHORT") .unwrap_or_else(|| full_hash.chars().take(7).collect()); - println!("cargo:rustc-env=DIOXUS_CLI_GIT_SHA_SHORT={short_hash}"); } } + +fn env_nonempty(key: &str) -> Option { + env::var(key).ok().filter(|s| !s.trim().is_empty()) +} + +fn emit_rerun(path: &Path) { + println!("cargo:rerun-if-changed={}", path.display()); +} + +/// Walk up from `start` looking for `.git`. Handles the file form used by +/// git worktrees and submodules, where `.git` contains `gitdir: `. +fn find_git_dir(start: &Path) -> Option { + for dir in start.ancestors() { + let candidate = dir.join(".git"); + let meta = match fs::metadata(&candidate) { + Ok(m) => m, + Err(_) => continue, + }; + if meta.is_dir() { + return Some(candidate); + } + if meta.is_file() { + let contents = fs::read_to_string(&candidate).ok()?; + let pointer = contents + .lines() + .find_map(|l| l.strip_prefix("gitdir:"))? + .trim(); + let resolved = if Path::new(pointer).is_absolute() { + PathBuf::from(pointer) + } else { + dir.join(pointer) + }; + return Some(resolved); + } + } + None +} + +/// If HEAD is a symbolic ref (`ref: refs/heads/foo`), return the target path. +fn head_ref_target(git_dir: &Path) -> Option { + let head = fs::read_to_string(git_dir.join("HEAD")).ok()?; + head.strip_prefix("ref:").map(|s| s.trim().to_owned()) +} + +fn read_head_sha(git_dir: &Path) -> Option { + let head = fs::read_to_string(git_dir.join("HEAD")).ok()?; + let head = head.trim(); + + let Some(target) = head.strip_prefix("ref:").map(str::trim) else { + // Detached HEAD: the file holds the SHA directly. + return is_sha(head).then(|| head.to_owned()); + }; + + // Loose ref file. + if let Ok(sha) = fs::read_to_string(git_dir.join(target)) { + let sha = sha.trim(); + if is_sha(sha) { + return Some(sha.to_owned()); + } + } + + // Packed refs (after `git gc` / shallow clones, refs only live here). + let packed = fs::read_to_string(git_dir.join("packed-refs")).ok()?; + for line in packed.lines() { + if line.starts_with('#') || line.starts_with('^') || line.is_empty() { + continue; + } + let (sha, name) = line.split_once(' ')?; + if name == target && is_sha(sha) { + return Some(sha.to_owned()); + } + } + None +} + +/// Cargo writes `.cargo_vcs_info.json` next to `Cargo.toml` when publishing, +/// recording the source SHA at publish time. This is how `cargo install` +/// from crates.io can still report a real commit despite the `.crate` +/// tarball having no `.git/`. +fn read_cargo_vcs_info(manifest_dir: &Path) -> Option { + let contents = fs::read_to_string(manifest_dir.join(".cargo_vcs_info.json")).ok()?; + // Cheap targeted parse so we don't pull serde into the build script. + // The file is generated by cargo and the field name is stable. + let after = contents.split("\"sha1\"").nth(1)?; + let sha = after.split('"').nth(1)?.trim(); + is_sha(sha).then(|| sha.to_owned()) +} + +fn git_cli_sha(manifest_dir: &Path) -> Option { + // Scope `git` to the manifest dir so we never accidentally pick up a + // SHA from a parent repo (e.g. someone's `~` is a git repo). + let output = Command::new("git") + .arg("-C") + .arg(manifest_dir) + .args(["rev-parse", "HEAD"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let sha = String::from_utf8(output.stdout).ok()?.trim().to_owned(); + is_sha(&sha).then_some(sha) +} + +fn is_sha(s: &str) -> bool { + matches!(s.len(), 40 | 64) && s.chars().all(|c| c.is_ascii_hexdigit()) +}