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
6 changes: 3 additions & 3 deletions docs/site/docs-en/ci/pipeline-triggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,13 @@ jobs:

## Concurrency safety

Since v5.2, `ferrflow release` acquires `.git/ferrflow.lock` atomically (`O_CREAT|O_EXCL`) at the start of every mutating run. A second concurrent invocation on the same repo fails fast with a clear error rather than racing on git refs: the classic failure mode is a manually-triggered release firing at the same time as a cron-driven `auto-release`, producing half-pushed tag sets, non-fast-forward rejects, or duplicate draft releases.
Since v5.2, `ferrflow release` acquires `ferrflow.lock` atomically (`O_CREAT|O_EXCL`) at the start of every mutating run. The lockfile lives in the repository's common git dir, which is `.git/` in an ordinary checkout and the main checkout's `.git/` when you run from a linked worktree, so all the worktrees of one repository share a single lock. That is what you want: they push to the same remote and compete on the same refs, so a per-worktree lock would not prevent anything. A second concurrent invocation on the same repo fails fast with a clear error rather than racing on git refs: the classic failure mode is a manually-triggered release firing at the same time as a cron-driven `auto-release`, producing half-pushed tag sets, non-fast-forward rejects, or duplicate draft releases.

You don't need to wire anything up. The lock is automatic on every `release` invocation. Read-only commands (`check`, `status`, `version`, `tag`) skip it.

If a previous run crashed without releasing the lock, the next invocation takes it over automatically after 30 minutes (the host + PID stamped inside the lockfile lets FerrFlow detect stale locks). To take it over sooner, delete `.git/ferrflow.lock` manually.
If a previous run crashed without releasing the lock, the next invocation takes it over automatically after 30 minutes (the host + PID stamped inside the lockfile lets FerrFlow detect stale locks). To take it over sooner, delete `ferrflow.lock` from that git dir manually.

<aside class="ferr-aside ferr-aside--note"><div class="ferr-aside__body"><p>The lock is per-repo, scoped to <code>.git/</code>. It does not protect across separate clones of the same repo: if you run releases concurrently from two different runners against two different checkouts of the same remote, the lock won&#39;t see the other side. Use a single release runner, or serialize at the CI level (<code>concurrency:</code> in GitHub Actions, <code>interruptible: false</code> in GitLab).</p>
<aside class="ferr-aside ferr-aside--note"><div class="ferr-aside__body"><p>The lock is per-repo, scoped to the common git dir. It covers every linked worktree, but it does not protect across separate clones of the same repo: if you run releases concurrently from two different runners against two different checkouts of the same remote, the lock won&#39;t see the other side. Use a single release runner, or serialize at the CI level (<code>concurrency:</code> in GitHub Actions, <code>interruptible: false</code> in GitLab).</p>
</div></aside>

## Crash-resume
Expand Down
6 changes: 3 additions & 3 deletions docs/site/docs-fr/ci/pipeline-triggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,13 @@ jobs:

## Securite de concurrence

Depuis la v5.2, `ferrflow release` acquiert `.git/ferrflow.lock` de maniere atomique (`O_CREAT|O_EXCL`) au debut de chaque execution mutante. Une seconde invocation concurrente sur le meme depot echoue immediatement avec une erreur claire au lieu de courir contre les refs git. Le scenario classique est une release declenchee manuellement qui demarre en meme temps qu'un `auto-release` planifie en cron, ce qui produit des jeux de tags poussés à moitié, des refus non fast-forward ou des draft releases dupliquees.
Depuis la v5.2, `ferrflow release` acquiert `ferrflow.lock` de maniere atomique (`O_CREAT|O_EXCL`) au debut de chaque execution mutante. Le lockfile se trouve dans le git dir commun du depot, c'est-a-dire `.git/` dans un checkout ordinaire et le `.git/` du checkout principal quand vous lancez depuis un worktree lie, si bien que tous les worktrees d'un depot partagent un seul verrou. C'est le comportement voulu : ils poussent vers le meme remote et se disputent les memes refs, donc un verrou par worktree n'empecherait rien. Une seconde invocation concurrente sur le meme depot echoue immediatement avec une erreur claire au lieu de courir contre les refs git. Le scenario classique est une release declenchee manuellement qui demarre en meme temps qu'un `auto-release` planifie en cron, ce qui produit des jeux de tags poussés à moitié, des refus non fast-forward ou des draft releases dupliquees.

Rien à brancher. Le verrou est automatique sur chaque invocation `release`. Les commandes en lecture seule (`check`, `status`, `version`, `tag`) ne le prennent pas.

Si une execution précédente a planté sans relacher le verrou, l'invocation suivante le reprend automatiquement apres 30 minutes (l'hote + le PID inscrits dans le lockfile permettent à FerrFlow de detecter les verrous orphelins). Pour le reprendre plus tot, supprimez `.git/ferrflow.lock` à la main.
Si une execution précédente a planté sans relacher le verrou, l'invocation suivante le reprend automatiquement apres 30 minutes (l'hote + le PID inscrits dans le lockfile permettent à FerrFlow de detecter les verrous orphelins). Pour le reprendre plus tot, supprimez `ferrflow.lock` de ce git dir a la main.

<aside class="ferr-aside ferr-aside--note"><div class="ferr-aside__body"><p>Le verrou est par-depot, scope a <code>.git/</code>. Il ne protege pas entre des clones separes du meme depot : si vous lancez des releases simultanees depuis deux runners differents contre deux checkouts du meme remote, le verrou ne voit pas l&#39;autre cote. Utilisez un seul runner de release, ou serialisez au niveau CI (<code>concurrency:</code> dans GitHub Actions, <code>interruptible: false</code> dans GitLab).</p>
<aside class="ferr-aside ferr-aside--note"><div class="ferr-aside__body"><p>Le verrou est par-depot, scope au git dir commun. Il couvre tous les worktrees lies, mais il ne protege pas entre des clones separes du meme depot : si vous lancez des releases simultanees depuis deux runners differents contre deux checkouts du meme remote, le verrou ne voit pas l&#39;autre cote. Utilisez un seul runner de release, ou serialisez au niveau CI (<code>concurrency:</code> dans GitHub Actions, <code>interruptible: false</code> dans GitLab).</p>
</div></aside>

## Reprise apres crash
Expand Down
132 changes: 96 additions & 36 deletions src/monorepo/run/lock.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::{Context, Result, anyhow};
use gix::Repository;
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
Expand All @@ -8,8 +9,10 @@ use crate::error_code::{self, ErrorCodeExt};

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.
/// RAII lock guard for `ferrflow release`. Acquires `ferrflow.lock` in the
/// repository's common git dir atomically via O_CREAT|O_EXCL, and releases
/// the file on drop. The common dir is shared by every linked worktree, so
/// one repository has one lock however many worktrees are checked out.
///
/// Prevents two concurrent `release` invocations on the same repo from
/// racing — typical scenario: a manually-triggered release running at
Expand All @@ -29,17 +32,8 @@ 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");
pub fn acquire(repo: &Repository) -> Result<Self> {
let path = lock_path(repo)?;

match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut file) => {
Expand All @@ -66,7 +60,7 @@ impl ReleaseLock {
"Warning: previous release lock at {} appeared stale; took it over.",
path.display()
);
return Self::acquire(repo_root);
return Self::acquire(repo);
}
let existing = read_lock_info(&path).unwrap_or_else(|| "<unreadable>".to_string());
Err(anyhow!(
Expand All @@ -87,17 +81,29 @@ impl ReleaseLock {

/// Force-acquire the lock, ignoring any existing one. Used by
/// `--force-unlock` for manual recovery.
pub fn acquire_force(repo_root: &Path) -> Result<Self> {
let path = repo_root.join(".git").join("ferrflow.lock");
pub fn acquire_force(repo: &Repository) -> Result<Self> {
let path = lock_path(repo)?;
if path.exists() {
let _ = std::fs::remove_file(&path);
tracing::warn!(
"Warning: --force-unlock removed existing lockfile at {}",
path.display()
);
}
Self::acquire(repo_root)
Self::acquire(repo)
}
}

fn lock_path(repo: &Repository) -> Result<PathBuf> {
if repo.workdir().is_none() {
return Err(anyhow!(
"release lock cannot acquire — {} is a bare repository, which has \
nothing to release from",
repo.common_dir().display()
))
.error_code(error_code::GIT_NOT_A_REPO);
}
Ok(repo.common_dir().join("ferrflow.lock"))
}

impl Drop for ReleaseLock {
Expand Down Expand Up @@ -140,33 +146,59 @@ fn hostname_or_unknown() -> String {
mod tests {
use super::*;

fn init_test_repo() -> tempfile::TempDir {
fn git(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("git should be on PATH");
assert!(status.success(), "git {args:?} failed");
}

fn init_test_repo() -> (tempfile::TempDir, Repository) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
dir
git(dir.path(), &["init", "-q", "-b", "main"]);
git(dir.path(), &["config", "user.email", "t@example.com"]);
git(dir.path(), &["config", "user.name", "t"]);
git(dir.path(), &["commit", "-q", "--allow-empty", "-m", "root"]);
let repo = crate::git::open_repo(dir.path()).unwrap();
(dir, repo)
}

fn add_worktree(main: &Path, name: &str) -> Repository {
let path = main.join(name);
git(
main,
&["worktree", "add", "-q", "-b", name, path.to_str().unwrap()],
);
crate::git::open_repo(&path).unwrap()
}

#[test]
fn acquire_in_clean_repo_succeeds() {
let dir = init_test_repo();
let _lock = ReleaseLock::acquire(dir.path()).expect("first acquire");
let (dir, repo) = init_test_repo();
let _lock = ReleaseLock::acquire(&repo).expect("first acquire");
assert!(dir.path().join(".git/ferrflow.lock").exists());
}

#[test]
fn drop_removes_the_lockfile() {
let dir = init_test_repo();
let (dir, repo) = init_test_repo();
{
let _lock = ReleaseLock::acquire(dir.path()).unwrap();
let _lock = ReleaseLock::acquire(&repo).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 (_dir, repo) = init_test_repo();
let _first = ReleaseLock::acquire(&repo).unwrap();
let err = ReleaseLock::acquire(&repo).expect_err("second acquire should fail");
let msg = format!("{err:?}");
assert!(
msg.contains("already running"),
Expand All @@ -176,24 +208,52 @@ mod tests {

#[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");
let (_dir, repo) = init_test_repo();
let first = ReleaseLock::acquire(&repo).unwrap();
let _second =
ReleaseLock::acquire_force(&repo).expect("force-unlock should succeed even if held");
drop(first);
}

#[test]
fn errors_when_git_dir_missing() {
fn a_bare_repo_errors_and_says_so() {
let dir = tempfile::tempdir().unwrap();
let err = ReleaseLock::acquire(dir.path()).expect_err("no .git → should error");
assert!(format!("{err:?}").contains(".git directory"));
git(dir.path(), &["init", "-q", "--bare", "bare.git"]);
let repo = crate::git::open_repo(&dir.path().join("bare.git")).unwrap();
let err = ReleaseLock::acquire(&repo).expect_err("bare repo should error");
let msg = format!("{err:?}");
assert!(msg.contains("bare repository"), "{msg}");
assert!(
!msg.contains("worktree"),
"the bare error should not blame worktrees: {msg}"
);
}

#[test]
fn a_worktree_can_acquire_and_shares_the_main_repository_lock() {
let (dir, main) = init_test_repo();
let worktree = add_worktree(dir.path(), "wt");

let lock = ReleaseLock::acquire(&worktree).expect("a worktree should be able to release");
assert!(
dir.path().join(".git/ferrflow.lock").exists(),
"the lock belongs in the common dir, not the per-worktree git dir"
);
assert!(
!dir.path().join("wt/.git").is_dir(),
"the worktree's .git should stay a file, so this proves the fallback is not in play"
);

let err = ReleaseLock::acquire(&main)
.expect_err("the main checkout must not release while a worktree holds the lock");
assert!(format!("{err:?}").contains("already running"), "{err:?}");
drop(lock);
}

#[test]
fn lockfile_content_includes_pid() {
let dir = init_test_repo();
let _lock = ReleaseLock::acquire(dir.path()).unwrap();
let (dir, repo) = init_test_repo();
let _lock = ReleaseLock::acquire(&repo).unwrap();
let content = std::fs::read_to_string(dir.path().join(".git/ferrflow.lock")).unwrap();
let expected = std::process::id().to_string();
assert!(
Expand Down
4 changes: 2 additions & 2 deletions src/monorepo/run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ pub(super) fn run_release_logic(
let _release_lock = if dry_run {
None
} else if force_unlock {
Some(lock::ReleaseLock::acquire_force(root)?)
Some(lock::ReleaseLock::acquire_force(&repo)?)
} else {
Some(lock::ReleaseLock::acquire(root)?)
Some(lock::ReleaseLock::acquire(&repo)?)
};

if !dry_run {
Expand Down
Loading