Skip to content
Merged
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: 6 additions & 0 deletions docs/site/data/schema/ferrflow-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,12 @@
"continue"
],
"default": "abort"
},
"timeout": {
"type": "integer",
"minimum": 1,
"description": "Seconds a hook may run before FerrFlow kills it and its child processes, then applies onFailure. Defaults to 900 (15 minutes).",
"default": 900
}
},
"additionalProperties": false
Expand Down
6 changes: 6 additions & 0 deletions docs/site/data/schema/ferrflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,12 @@
"continue"
],
"default": "abort"
},
"timeout": {
"type": "integer",
"minimum": 1,
"description": "Seconds a hook may run before FerrFlow kills it and its child processes, then applies onFailure. Defaults to 900 (15 minutes).",
"default": 900
}
},
"additionalProperties": false
Expand Down
3 changes: 3 additions & 0 deletions docs/site/docs-en/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ on_failure = "abort"
| `onSuccess` | string | — | Run once after the whole release completes cleanly |
| `onError` | string | — | Run once when the release fails; sets `FERRFLOW_ERROR_CODE` (once per run) |
| `onFailure` | string | `"abort"` | Strategy — `"abort"` cancels the release on hook failure, `"continue"` prints a warning |
| `timeout` | number | `900` | Seconds a hook may run before it is killed and `onFailure` applies |

### Environment variables

Expand Down Expand Up @@ -807,6 +808,8 @@ For the once-per-run hooks (`preRelease`, `onSuccess`, `onError`) the per-packag

`onFailure` is the failure **strategy** (`abort` / `continue`), not a command. The command that runs _when_ a release fails is `onError`, which receives the failing `FERRFLOW_ERROR_CODE`.

Hooks run with stdin closed, so a command that would prompt (`npm login`, a `gpg` passphrase, a git credential prompt) fails at once instead of waiting forever. A hook still running after `timeout` seconds is killed along with any process it started, and counts as a failed hook: `abort` stops the release, `continue` warns and moves on. Every minute a long hook prints that it is still running. Set `timeout` per package to override the workspace value. On Linux and macOS a hook runs in its own process group so it can be killed with everything it started, which also means a Ctrl+C on an interactive `ferrflow release` stops FerrFlow but not a hook that is already running; the timeout still bounds it.
Comment thread
BryanFRD marked this conversation as resolved.

### Per-package hooks

Package-level hooks **replace** workspace-level hooks for that package (they are not merged).
Expand Down
3 changes: 3 additions & 0 deletions docs/site/docs-fr/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ on_failure = "abort"
| `onSuccess` | string | — | Exécuté une fois après une release entièrement réussie |
| `onError` | string | — | Exécuté une fois quand la release échoue ; définit `FERRFLOW_ERROR_CODE` (une fois) |
| `onFailure` | string | `"abort"` | Stratégie — `"abort"` annule la release en cas d'échec de hook, `"continue"` affiche un avertissement |
| `timeout` | number | `900` | Secondes qu'un hook peut tourner avant d'être tué et que `onFailure` s'applique |

### Variables d'environnement

Expand Down Expand Up @@ -666,6 +667,8 @@ Pour les hooks exécutés une seule fois par run (`preRelease`, `onSuccess`, `on

`onFailure` est la **stratégie** d'échec (`abort` / `continue`), pas une commande. La commande exécutée _quand_ une release échoue est `onError`, qui reçoit le `FERRFLOW_ERROR_CODE` fautif.

Les hooks s'exécutent avec stdin fermé : une commande qui poserait une question (`npm login`, une passphrase `gpg`, une demande d'identifiants git) échoue tout de suite au lieu d'attendre indéfiniment. Un hook encore en cours après `timeout` secondes est tué avec les processus qu'il a lancés, et compte comme un hook en échec : `abort` arrête la release, `continue` affiche un avertissement et poursuit. Chaque minute, un hook long indique qu'il tourne toujours. `timeout` défini sur un package remplace la valeur du workspace. Sous Linux et macOS, un hook tourne dans son propre groupe de processus pour pouvoir être tué avec tout ce qu'il a lancé : un Ctrl+C sur un `ferrflow release` interactif arrête donc FerrFlow mais pas un hook déjà en cours, qui reste borné par son timeout.

### Hooks par package

Les hooks au niveau du package **remplacent** les hooks du workspace pour ce package (ils ne sont pas fusionnés).
Expand Down
6 changes: 6 additions & 0 deletions schema/ferrflow-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,12 @@
"continue"
],
"default": "abort"
},
"timeout": {
"type": "integer",
"minimum": 1,
"description": "Seconds a hook may run before FerrFlow kills it and its child processes, then applies onFailure. Defaults to 900 (15 minutes).",
"default": 900
}
},
"additionalProperties": false
Expand Down
6 changes: 6 additions & 0 deletions schema/ferrflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,12 @@
"continue"
],
"default": "abort"
},
"timeout": {
"type": "integer",
"minimum": 1,
"description": "Seconds a hook may run before FerrFlow kills it and its child processes, then applies onFailure. Defaults to 900 (15 minutes).",
"default": 900
}
},
"additionalProperties": false
Expand Down
2 changes: 2 additions & 0 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ pub struct HooksConfig {
pub on_error: Option<String>,
#[serde(default, alias = "onFailure")]
pub on_failure: Option<OnFailure>,
#[serde(default)]
pub timeout: Option<std::num::NonZeroU64>,
}

#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)]
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ mod runner;

pub use context::{HookCommit, HookContext, HookFile, HookPackage};
pub use point::HookPoint;
pub use resolve::{resolve_hook, resolve_on_failure};
pub use resolve::{HookPolicy, resolve_hook, resolve_policy};
pub use runner::{capture_build_metadata, run_hook};
65 changes: 65 additions & 0 deletions src/hooks/resolve.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
use std::time::Duration;

use crate::config::{HooksConfig, OnFailure};

use super::HookPoint;

pub const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(15 * 60);

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HookPolicy {
pub on_failure: OnFailure,
pub timeout: Duration,
}

pub fn resolve_policy(
pkg_hooks: Option<&HooksConfig>,
ws_hooks: Option<&HooksConfig>,
) -> HookPolicy {
let timeout = pkg_hooks
.and_then(|h| h.timeout)
.or_else(|| ws_hooks.and_then(|h| h.timeout))
.map_or(DEFAULT_HOOK_TIMEOUT, |secs| Duration::from_secs(secs.get()));
HookPolicy {
on_failure: resolve_on_failure(pkg_hooks, ws_hooks),
timeout,
}
}

pub fn resolve_hook(
pkg_hooks: Option<&HooksConfig>,
ws_hooks: Option<&HooksConfig>,
Expand Down Expand Up @@ -58,6 +82,7 @@ pub fn resolve_on_failure(
#[cfg(test)]
mod tests {
use super::*;
use std::num::NonZeroU64;

fn ws_hooks(pre_bump: Option<&str>, post_publish: Option<&str>) -> HooksConfig {
HooksConfig {
Expand Down Expand Up @@ -98,6 +123,45 @@ mod tests {
assert!(result.is_none());
}

#[test]
fn a_zero_timeout_is_rejected_when_the_config_is_read() {
let zero = serde_json::from_str::<HooksConfig>(r#"{"timeout": 0}"#);
assert!(zero.is_err());
let ten = serde_json::from_str::<HooksConfig>(r#"{"timeout": 10}"#).unwrap();
assert_eq!(ten.timeout, NonZeroU64::new(10));
}

#[test]
fn timeout_defaults_to_fifteen_minutes() {
assert_eq!(resolve_policy(None, None).timeout, DEFAULT_HOOK_TIMEOUT);
assert_eq!(DEFAULT_HOOK_TIMEOUT, Duration::from_secs(900));
}

#[test]
fn timeout_comes_from_the_package_then_the_workspace() {
let ws = HooksConfig {
timeout: NonZeroU64::new(120),
..Default::default()
};
let pkg = HooksConfig {
timeout: NonZeroU64::new(30),
..Default::default()
};

assert_eq!(
resolve_policy(None, Some(&ws)).timeout,
Duration::from_secs(120)
);
assert_eq!(
resolve_policy(Some(&pkg), Some(&ws)).timeout,
Duration::from_secs(30)
);
assert_eq!(
resolve_policy(Some(&HooksConfig::default()), Some(&ws)).timeout,
Duration::from_secs(120)
);
}

#[test]
fn on_failure_defaults_to_abort() {
assert_eq!(resolve_on_failure(None, None), OnFailure::Abort);
Expand Down Expand Up @@ -140,6 +204,7 @@ mod tests {
on_success: Some("j".into()),
on_error: Some("k".into()),
on_failure: None,
timeout: None,
};
for (point, expected) in [
(HookPoint::PreBump, "a"),
Expand Down
89 changes: 39 additions & 50 deletions src/hooks/runner.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
mod process;

use crate::config::OnFailure;
use crate::error_code::{self, ErrorCodeExt};
use anyhow::Context as _;
use anyhow::Result;
use colored::Colorize;
use std::path::Path;
use std::process::{Command, Stdio};
use std::process::Command;

use super::{HookContext, HookPoint};
use super::{HookContext, HookPoint, HookPolicy};
use process::{Finish, run_bounded};

pub fn run_hook(
point: HookPoint,
command: &str,
ctx: &HookContext,
on_failure: OnFailure,
policy: HookPolicy,
dry_run: bool,
verbose: bool,
working_dir: &Path,
Expand All @@ -34,34 +37,35 @@ pub fn run_hook(
command
);

let mut cmd = build_hook_command(command, ctx, working_dir);

if verbose {
let status = cmd
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;

if !status.success() {
return handle_failure(point, command, status.code(), on_failure);
}
} else {
let output = cmd.output()?;

if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.is_empty() {
eprint!("{stdout}");
}
if !stderr.is_empty() {
eprint!("{stderr}");
}
return handle_failure(point, command, output.status.code(), on_failure);
let cmd = build_hook_command(command, ctx, working_dir);

match run_bounded(cmd, !verbose, policy.timeout, point.label())? {
Finish::Exited { status, .. } if status.success() => Ok(()),
Finish::Exited {
status,
stdout,
stderr,
} => {
echo_captured(&stdout);
echo_captured(&stderr);
let code = status
.code()
.map_or_else(|| "signal".to_string(), |c| c.to_string());
handle_failure(point, command, &format!("exit {code}"), policy.on_failure)
}
Finish::TimedOut => handle_failure(
point,
command,
&format!("timed out after {}s", policy.timeout.as_secs()),
policy.on_failure,
),
}
}

Ok(())
fn echo_captured(output: &[u8]) {
if !output.is_empty() {
eprint!("{}", String::from_utf8_lossy(output));
}
}

fn build_hook_command(command: &str, ctx: &HookContext, working_dir: &Path) -> Command {
Expand Down Expand Up @@ -124,29 +128,21 @@ fn build_command(command: &str) -> Command {
fn handle_failure(
point: HookPoint,
command: &str,
code: Option<i32>,
reason: &str,
on_failure: OnFailure,
) -> Result<()> {
let code_str = code
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string());

match on_failure {
OnFailure::Abort => Err(anyhow::anyhow!(
"hook [{}] failed (exit {}): {}",
point.label(),
code_str,
command
"hook [{}] failed ({reason}): {command}",
point.label()
))
.error_code(error_code::HOOK_FAILED)?,
OnFailure::Continue => {
tracing::warn!(
"{}",
format!(
" Warning: hook [{}] failed (exit {}): {}",
point.label(),
code_str,
command
" Warning: hook [{}] failed ({reason}): {command}",
point.label()
)
.yellow()
);
Expand Down Expand Up @@ -181,7 +177,7 @@ mod tests {

#[test]
fn handle_failure_abort_returns_error() {
let result = handle_failure(HookPoint::PreBump, "echo fail", Some(1), OnFailure::Abort);
let result = handle_failure(HookPoint::PreBump, "echo fail", "exit 1", OnFailure::Abort);
assert!(result.is_err());
let msg = format!("{:?}", result.unwrap_err());
assert!(msg.contains("pre_bump"));
Expand All @@ -194,18 +190,11 @@ mod tests {
let result = handle_failure(
HookPoint::PostBump,
"echo fail",
Some(42),
"exit 42",
OnFailure::Continue,
);
assert!(result.is_ok());
}

#[test]
fn handle_failure_signal_no_exit_code() {
let result = handle_failure(HookPoint::PreCommit, "killed", None, OnFailure::Abort);
assert!(result.is_err());
assert!(format!("{:?}", result.unwrap_err()).contains("signal"));
}
}

#[cfg(test)]
Expand Down
Loading
Loading