Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/error_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
213 changes: 213 additions & 0 deletions src/monorepo/run/lock.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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(|| "<unreadable>".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<Self> {
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<String> {
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<bool> {
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:?}"
);
}
}
11 changes: 11 additions & 0 deletions src/monorepo/run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down
Loading