Skip to content
18 changes: 18 additions & 0 deletions site/content/docs/install/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,24 @@ The neutral `allowBuilds` field grants permission, and it keys on the package na

That field applies in any project, whoever owns it. A project owned by another package manager grants permission through its own field as well: pnpm projects use `pnpm.onlyBuiltDependencies` / `pnpm.allowBuilds`, and Bun projects use `trustedDependencies`. A package that wants to build but isn't allowed is skipped, with `WARN_NUB_IGNORED_BUILD_SCRIPTS` naming it and `nub approve-builds` as the remedy.

### When a build fails

A failed build fails the install. The one exception is a package reachable only through `optionalDependencies`, which the project has declared it can work without: that failure is reported and the install continues, matching npm and pnpm.

```console
# captured: nub 0.7.5, optfail@1.0.0 whose postinstall exits 3, allowBuilds entry present
$ nub install
WARN optfail@1.0.0 is an optional dependency and failed to build; continuing
without it: lifecycle script postinstall failed for optfail@1.0.0: script
`postinstall` exited with code 3 code=WARN_NUB_OPTIONAL_BUILD_FAILED
optionalDependencies:
+ optfail@1.0.0
```

Optionality is a property of the edge, not the package. A package that anything reaches through a normal dependency is required, and its build failure still fails the install, even when something else depends on it optionally.

The warning is emitted by the install that runs the build. A later install with nothing to do skips the package along with everything else, so it does not repeat. Run `nub rebuild <pkg>` to attempt the build again and see the failure.

### Cooling window

A registry-resolved version must be older than `minimumReleaseAge` — 24 hours by default — before Nub will install it. The window is what keeps a compromised publish out of your tree during the hours between it going up and being caught.
Expand Down
7 changes: 7 additions & 0 deletions vendor/aube/crates/aube-codes/src/warnings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub const WARN_AUBE_HOOK_PACKAGE_ADDED: &str = "WARN_AUBE_HOOK_PACKAGE_ADDED";
// ── install lifecycle ───────────────────────────────────────────────
pub const WARN_AUBE_IGNORED_BUILD_SCRIPTS: &str = "WARN_AUBE_IGNORED_BUILD_SCRIPTS";
pub const WARN_AUBE_DEFAULT_TRUST_BUILDS: &str = "WARN_AUBE_DEFAULT_TRUST_BUILDS";
#[rustfmt::skip] pub const WARN_AUBE_OPTIONAL_BUILD_FAILED: &str = "WARN_AUBE_OPTIONAL_BUILD_FAILED";
#[rustfmt::skip] pub const WARN_AUBE_NODE_GYP_BOOTSTRAP_FAILED: &str = "WARN_AUBE_NODE_GYP_BOOTSTRAP_FAILED";
#[rustfmt::skip] pub const WARN_AUBE_SUSPICIOUS_LIFECYCLE_SCRIPT: &str = "WARN_AUBE_SUSPICIOUS_LIFECYCLE_SCRIPT";
#[rustfmt::skip] pub const WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE: &str = "WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE";
Expand Down Expand Up @@ -205,6 +206,12 @@ pub const ALL: &[CodeMeta] = &[
description: "The `defaultTrust` floor let listed packages run build scripts without an explicit `allowBuilds` entry. Disclosure, not an error — set `defaultTrust=false` or an explicit `allowBuilds: false` entry to opt out.",
exit_code: None,
},
CodeMeta {
name: WARN_AUBE_OPTIONAL_BUILD_FAILED,
category: category::INSTALL_LIFECYCLE,
description: "A package reachable only through `optionalDependencies` failed to build. The install continues without it (npm and pnpm both treat an optional build failure as non-fatal). Anything importing it at runtime will fail — declare it a regular dependency if it is actually required. Emitted by the install that runs the build: a later up-to-date install skips the package and does not repeat this, so run `aube rebuild <pkg>` to attempt the build again.",
exit_code: None,
},
CodeMeta {
name: WARN_AUBE_NODE_GYP_BOOTSTRAP_FAILED,
category: category::INSTALL_LIFECYCLE,
Expand Down
88 changes: 76 additions & 12 deletions vendor/aube/crates/aube-resolver/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,16 +364,35 @@ pub fn filter_graph(
/// `optionalDependencies`). pnpm derives this during resolution; aube
/// recomputes it as a post-resolve pass so freshly resolved lockfiles
/// carry the same markers pnpm writes instead of an empty `{}` snapshot.
///
/// Algorithm: seed a `required` set from every non-optional direct
/// dependency of every importer, then walk each required package's
/// *non-optional* edges. A package's non-optional edges are its
/// `dependencies` minus its `optional_dependencies`, because the pnpm
/// parser mirrors active optional edges into `dependencies`. Any package
/// not reached this way is optional. A single fully-required path keeps a
/// package required even when other paths to it are optional, matching
/// pnpm.
pub fn mark_optional_packages(graph: &mut aube_lockfile::LockfileGraph) {
let optional = optional_only_packages(graph);
for (dep_path, pkg) in graph.packages.iter_mut() {
pkg.optional = optional.contains(dep_path);
}
}

/// The dep_paths [`mark_optional_packages`] would mark `optional: true`,
/// derived from the graph's own edges without mutating it.
///
/// Exists because `LockedPackage::optional` is only populated on two paths —
/// the pnpm reader and the fresh-resolve pass above — so a frozen install off
/// an npm / bun / yarn lockfile carries `optional: false` on every package even
/// though the graph still describes the optional edges. A consumer whose
/// behavior must not silently vary by incumbent lockfile format asks here
/// instead of reading the field.
///
/// Algorithm: seed a `required` set from every non-optional direct dependency of
/// every importer, then walk each required package's *non-optional* edges. A
/// package's non-optional edges are its `dependencies` minus its
/// `optional_dependencies`, because the pnpm parser mirrors active optional
/// edges into `dependencies`. Any package not reached this way is optional. A
/// single fully-required path keeps a package required even when other paths to
/// it are optional, matching pnpm and npm (whose `calcDepFlags` states the same
/// invariant: "a node still flagged optional must only be reachable via optional
/// edges").
pub fn optional_only_packages(
graph: &aube_lockfile::LockfileGraph,
) -> aube_util::collections::FxSet<String> {
use crate::FxHashSet;
use aube_lockfile::DepType;

Expand Down Expand Up @@ -410,9 +429,12 @@ pub fn mark_optional_packages(graph: &mut aube_lockfile::LockfileGraph) {
}
}
}
for (dep_path, pkg) in graph.packages.iter_mut() {
pkg.optional = !required.contains(dep_path);
}
graph
.packages
.keys()
.filter(|dep_path| !required.contains(*dep_path))
.cloned()
.collect()
}

/// Populate each package's `transitive_peer_dependencies` the way pnpm
Expand Down Expand Up @@ -750,6 +772,48 @@ mod tests {
assert!(is_opt("opt-root@1.0.0"));
}

// The install's build-failure classification reads this set directly rather
// than `LockedPackage::optional`, so the graph — not the stamped field — has
// to be what decides. Every package here is left `optional: false` (the
// state a frozen install off an npm / bun / yarn lockfile arrives in): if
// the classification ever regressed to reading the field, this test would
// find nothing optional at all.
#[test]
fn optional_only_packages_reads_edges_not_the_stamped_flag() {
use aube_lockfile::DepType;
let mut graph = aube_lockfile::LockfileGraph::default();
graph.importers.insert(
".".to_string(),
vec![
dep("host", DepType::Production),
dep("also-required", DepType::Production),
],
);
graph.packages.extend([
pkg("host", &["native", "dual"], &["native", "dual"]),
pkg("also-required", &["dual"], &[]),
pkg("native", &["native-child"], &[]),
pkg("native-child", &[], &[]),
pkg("dual", &[], &[]),
]);
assert!(
graph.packages.values().all(|p| !p.optional),
"fixture must leave the stamped flag unset"
);

let optional = optional_only_packages(&graph);

// Reachable only under `host`'s optional edge — and so is its own child.
assert!(optional.contains("native@1.0.0"));
assert!(optional.contains("native-child@1.0.0"));
// The control that proves this can't swallow a required package's build
// failure: `dual` hangs off an optional edge from `host` AND a required
// edge from `also-required`, so one fully-required path keeps it required.
assert!(!optional.contains("dual@1.0.0"));
assert!(!optional.contains("host@1.0.0"));
assert!(!optional.contains("also-required@1.0.0"));
}

fn pkg_with_peers(
name: &str,
deps: &[&str],
Expand Down
66 changes: 58 additions & 8 deletions vendor/aube/crates/aube/src/commands/install/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,9 @@ pub(crate) async fn run_dep_lifecycle_scripts(
package_dir: std::path::PathBuf,
manifest: aube_manifest::PackageJson,
cache_entry: Option<SideEffectsCacheEntry>,
/// Graph key, kept so the optional-only classification below resolves
/// per job without re-walking the graph.
dep_path: String,
}

let mut jobs: Vec<BuildJob> = Vec::new();
Expand Down Expand Up @@ -559,13 +562,25 @@ pub(crate) async fn run_dep_lifecycle_scripts(
package_dir,
manifest: dep_manifest,
cache_entry,
dep_path: dep_path.clone(),
});
}

if jobs.is_empty() {
return Ok(0);
}

// A package reachable ONLY through `optionalDependencies` is one the
// project declared it can live without, so a failed build for it is
// non-fatal — npm (`_handleOptionalFailure` during reify) and pnpm
// (`buildDependency`'s catch, which logs `reason: 'build_failure'` and
// returns) both continue the install. Computed from the graph's edges
// rather than read off `LockedPackage::optional`, which only the pnpm
// reader and the fresh-resolve pass populate; reading the field would make
// this silently inert on a frozen install off an npm/bun/yarn lockfile.
// A package with even one fully-required path stays required.
let optional_only = aube_resolver::platform::optional_only_packages(graph);

// Name what the floor let through — the floor must never be a
// silent allow path. One line, not per-package, so big graphs
// don't drown the install output. Emitted at `warn` with a stable
Expand Down Expand Up @@ -652,13 +667,19 @@ pub(crate) async fn run_dep_lifecycle_scripts(
let should_save_side_effects_cache = side_effects_cache.should_save();
let overwrite_side_effects_cache = side_effects_cache.overwrite_existing();
let jail_policy = std::sync::Arc::new((*jail_policy).clone());
let mut set: tokio::task::JoinSet<miette::Result<usize>> = tokio::task::JoinSet::new();
// `(optional, spec, outcome)` rather than a bare result: the drain loop has
// to know whether the package that failed was optional-only, and an error
// surfacing from `join_next` carries no identity of its own.
let mut set: tokio::task::JoinSet<(bool, String, miette::Result<usize>)> =
tokio::task::JoinSet::new();
for job in jobs {
let sem = semaphore.clone();
let project_dir = project_dir.clone();
let modules_dir_name = modules_dir_name.clone();
let node_gyp_bin_dir = node_gyp_bin_dir.clone();
let jail_policy = jail_policy.clone();
let job_optional = optional_only.contains(&job.dep_path);
let job_spec = format!("{}@{}", job.name, job.version);
let task = crate::dep_chain::scope_current(async move {
let _permit = sem.acquire().await.unwrap();
if should_restore_side_effects_cache && let Some(cache_entry) = job.cache_entry.clone()
Expand Down Expand Up @@ -812,17 +833,46 @@ pub(crate) async fn run_dep_lifecycle_scripts(
});
let task = crate::runtime::scope_current(task);
let task = aube_scripts::scope_current(task);
set.spawn(task);
set.spawn(async move { (job_optional, job_spec, task.await) });
}

let mut ran = 0usize;
while let Some(res) = set.join_next().await {
// `?` on the outer `Result` propagates a real task-level panic
// (tokio's `JoinError`); `?` on the inner `miette::Result`
// propagates a script failure. Either way, the function
// returns, `set` is dropped, and the remaining in-flight
// scripts are aborted before they can scribble on disk.
ran += res.into_diagnostic()??;
// A `JoinError` here is a task-level panic — an aube bug, not a package
// whose build failed — so it stays fatal even for an optional package.
let (optional, spec, outcome) = res.into_diagnostic()?;
match outcome {
Ok(count) => ran += count,
// An optional-only package's build failure is not the install's
// error: npm (`_handleOptionalFailure` during reify) and pnpm
// (`buildDependency`'s catch) both continue. Warned per package
// rather than swallowed — a native addon that silently never built
// is miserable to trace back from the runtime import error.
//
// Deliberately ONE-SHOT, which is the trade this fix makes.
// Returning `Ok` lets `run_finalize_phase` reach
// `state::write_state`, which the pre-fix `?` short-circuit never
// did. `delta::fingerprint` hashes resolve identity only and
// records nothing about build success, so the next install finds
// no delta and skips the package at the `selected_dep_paths` guard
// above — the warning does not repeat. That matches pnpm exactly
// (measured: a second `pnpm install` over a failed optional build
// is silent), though npm does re-run the build every install. The
// retry the pre-fix code appeared to offer was only a side effect
// of failing the install outright, which is the defect being fixed
// here. Re-warning across installs would need the package
// persisted into install state and re-emitted from the up-to-date
// short-circuit, the way `unreviewed_builds` already is.
Err(error) if optional => {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_OPTIONAL_BUILD_FAILED,
"{spec} is an optional dependency and failed to build; continuing without it: {error}"
);
}
// A required package's failure returns immediately, as before:
// `set` is dropped and the not-yet-started jobs never run.
Err(error) => return Err(error),
}
}
Ok(ran)
}
Expand Down
Loading
Loading