diff --git a/src/error_code.rs b/src/error_code.rs index 8546a28..4252eaa 100644 --- a/src/error_code.rs +++ b/src/error_code.rs @@ -103,6 +103,8 @@ pub const GIT_REMOTE_NOT_FOUND: ErrorCode = ErrorCode(2008); pub const GIT_PUSH_VERIFY_FAILED: ErrorCode = ErrorCode(2009); #[allow(dead_code)] pub const GIT_REMOTE_BRANCH_NOT_FOUND: ErrorCode = ErrorCode(2010); +#[allow(dead_code)] +pub const GIT_LOCKED: ErrorCode = ErrorCode(2011); #[allow(dead_code)] pub const GITHUB_CREATE_RELEASE: ErrorCode = ErrorCode(3001); diff --git a/src/monorepo/run/lock.rs b/src/monorepo/run/lock.rs new file mode 100644 index 0000000..8360263 --- /dev/null +++ b/src/monorepo/run/lock.rs @@ -0,0 +1,213 @@ +use anyhow::{Context, Result, anyhow}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::error_code::{self, ErrorCodeExt}; + +/// Stale-lock TTL. A lockfile older than this is treated as orphaned +/// (the previous ferrflow run crashed without releasing it). 30 minutes +/// is comfortably longer than any realistic monorepo release. +const STALE_LOCK_TTL: Duration = Duration::from_secs(30 * 60); + +/// RAII lock guard for `ferrflow release`. Acquires `.git/ferrflow.lock` +/// atomically via O_CREAT|O_EXCL. Releases the file on drop. +/// +/// Prevents two concurrent `release` invocations on the same repo from +/// racing — typical scenario: a manually-triggered release running at +/// the same time as the cron-driven `auto-release` workflow. Without +/// this guard they compete on git refs (half-pushed tag sets, non-FF +/// rejects, duplicate draft releases). +/// +/// Read-only commands (`check`, `status`, `version`, `tag`) don't take +/// the lock — only mutation paths need it. +#[derive(Debug)] +pub struct ReleaseLock { + path: PathBuf, + /// Held open for the duration of the release run. Dropping the File + /// closes the descriptor; Drop on the guard also unlinks the path. + _handle: File, +} + +impl ReleaseLock { + /// Try to acquire the release lock. Returns Err if another live + /// release is in progress. Stale locks (older than STALE_LOCK_TTL + /// with the PID no longer alive) are taken over with a warning. + pub fn acquire(repo_root: &Path) -> Result { + let git_dir = repo_root.join(".git"); + if !git_dir.is_dir() { + return Err(anyhow!( + "release lock cannot acquire — {} is not a regular .git directory \ + (worktrees and submodules currently unsupported by the lock)", + git_dir.display() + )) + .error_code(error_code::GIT_NOT_A_REPO); + } + let path = git_dir.join("ferrflow.lock"); + + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut file) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let _ = writeln!( + file, + "{pid}\n{now}\n{host}", + pid = std::process::id(), + host = hostname_or_unknown() + ); + let _ = file.flush(); + Ok(Self { + path, + _handle: file, + }) + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + if take_over_if_stale(&path)? { + eprintln!( + "Warning: previous release lock at {} appeared stale; took it over.", + path.display() + ); + return Self::acquire(repo_root); + } + let existing = read_lock_info(&path).unwrap_or_else(|| "".to_string()); + Err(anyhow!( + "another `ferrflow release` is already running on this repo (lockfile: {})\n \ + lock content:\n {}\n \ + If you're sure no other release is in progress, delete the lockfile manually \ + and retry (or run with --force-unlock).", + path.display(), + existing.replace('\n', "\n ") + )) + .error_code(error_code::GIT_LOCKED) + } + Err(e) => Err(e) + .with_context(|| format!("could not create release lock at {}", path.display())) + .error_code(error_code::GIT_LOCKED), + } + } + + /// Force-acquire the lock, ignoring any existing one. Used by + /// `--force-unlock` for manual recovery. + #[allow(dead_code)] + pub fn acquire_force(repo_root: &Path) -> Result { + let path = repo_root.join(".git").join("ferrflow.lock"); + if path.exists() { + let _ = std::fs::remove_file(&path); + eprintln!( + "Warning: --force-unlock removed existing lockfile at {}", + path.display() + ); + } + Self::acquire(repo_root) + } +} + +impl Drop for ReleaseLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn read_lock_info(path: &Path) -> Option { + let mut buf = String::new(); + File::open(path).ok()?.read_to_string(&mut buf).ok()?; + Some(buf) +} + +/// Returns true if the lock was deleted (stale) and the caller can retry. +fn take_over_if_stale(path: &Path) -> Result { + let metadata = match std::fs::metadata(path) { + Ok(m) => m, + Err(_) => return Ok(false), + }; + let modified = metadata + .modified() + .ok() + .and_then(|t| t.elapsed().ok()) + .unwrap_or(Duration::ZERO); + if modified < STALE_LOCK_TTL { + return Ok(false); + } + // Older than TTL — take it over. + let _ = std::fs::remove_file(path); + Ok(true) +} + +fn hostname_or_unknown() -> String { + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn init_test_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + dir + } + + #[test] + fn acquire_in_clean_repo_succeeds() { + let dir = init_test_repo(); + let _lock = ReleaseLock::acquire(dir.path()).expect("first acquire"); + assert!(dir.path().join(".git/ferrflow.lock").exists()); + } + + #[test] + fn drop_removes_the_lockfile() { + let dir = init_test_repo(); + { + let _lock = ReleaseLock::acquire(dir.path()).unwrap(); + } + assert!(!dir.path().join(".git/ferrflow.lock").exists()); + } + + #[test] + fn second_acquire_fails_while_first_held() { + let dir = init_test_repo(); + let _first = ReleaseLock::acquire(dir.path()).unwrap(); + let err = ReleaseLock::acquire(dir.path()).expect_err("second acquire should fail"); + let msg = format!("{err:?}"); + assert!( + msg.contains("already running"), + "expected lock-busy error, got: {msg}" + ); + } + + #[test] + fn force_unlock_takes_over_active_lock() { + let dir = init_test_repo(); + let first = ReleaseLock::acquire(dir.path()).unwrap(); + let _second = ReleaseLock::acquire_force(dir.path()) + .expect("force-unlock should succeed even if held"); + // First drops at end of scope; second's path still points to the + // same file, so the second drop will also try to remove. Either + // way the file is gone at scope end. + drop(first); + } + + #[test] + fn errors_when_git_dir_missing() { + let dir = tempfile::tempdir().unwrap(); + let err = ReleaseLock::acquire(dir.path()).expect_err("no .git → should error"); + assert!(format!("{err:?}").contains(".git directory")); + } + + #[test] + fn lockfile_content_includes_pid() { + let dir = init_test_repo(); + let _lock = ReleaseLock::acquire(dir.path()).unwrap(); + let content = std::fs::read_to_string(dir.path().join(".git/ferrflow.lock")).unwrap(); + let expected = std::process::id().to_string(); + assert!( + content.starts_with(&expected), + "expected lock content to start with PID {expected}, got: {content:?}" + ); + } +} diff --git a/src/monorepo/run/mod.rs b/src/monorepo/run/mod.rs index 37d16c1..2576711 100644 --- a/src/monorepo/run/mod.rs +++ b/src/monorepo/run/mod.rs @@ -31,6 +31,7 @@ use super::util::{ mod drafts; mod forced; +mod lock; mod summary; use drafts::publish_pending_drafts; use forced::{Forced, forced_version_for, parse_forced_version}; @@ -65,6 +66,16 @@ pub(super) fn run_release_logic( let repo = open_repo(root)?; + // Acquire the release lock before any mutating step. Dropped at the + // end of run_release_logic via RAII. Skipped on dry-run (no writes). + // The lockfile lives at .git/ferrflow.lock; concurrent invocations + // get a clear error instead of racing on git refs. See #514. + let _release_lock = if dry_run { + None + } else { + Some(lock::ReleaseLock::acquire(root)?) + }; + if !dry_run && let Err(e) = fetch_tags(&repo, &config.workspace.remote) && verbose