Drafted with the help of Claude Fable.
Use case: we run moon tasks in CI, where the pipeline must treat certain exit codes as allowed
failures and all others as job failures (e.g. GitLab's allow_failure: exit_codes). That needs the
task's real exit code; moon run collapses every failure to 1, so exit-code-based job status
cannot be driven through moon.
Steps to reproduce
Reproducer: https://github.com/k0pernikus/moon-run-swallows-exit-codes — a workspace with one task
wrapping a script that exits a random code from 70 / 80 / 90 (codes a pipeline would
allow-list), moon pinned to 2.4.2 via mise, and a GitHub workflow that runs the task bare.
fail.sh:
#!/usr/bin/env bash
set -euo pipefail
codes=(70 80 90)
code="${codes[$((RANDOM % ${#codes[@]}))]}"
echo "fail.sh exiting with code ${code}" >&2
exit "${code}"
moon.yml:
language: bash
tasks:
fail-with-random-allowed-to-fail-codes:
command: './fail.sh'
toolchain: system
options:
cache: false
runFromWorkspaceRoot: true
Run the task and inspect the exit code:
$ moon run demo:fail-with-random-allowed-to-fail-codes; echo "exit: $?"
× Task demo:fail-with-random-allowed-to-fail-codes failed to run.
╰─▶ Process ./fail.sh failed: exit code 80
exit: 1
Run the script directly for contrast:
$ ./fail.sh; echo "exit: $?"
fail.sh exiting with code 80
exit: 80
CI run showing the same:
https://github.com/k0pernikus/moon-run-swallows-exit-codes/actions/runs/29156794001/job/86555120128
Run moon run demo:fail-with-random-allowed-to-fail-codes
▮▮▮▮ demo:fail-with-random-allowed-to-fail-codes (8d5f7a1c)
fail.sh exiting with code 80
▮▮▮▮ demo:fail-with-random-allowed-to-fail-codes (4ms, 8d5f7a1c)
Error: task_runner::run_failed
× Task demo:fail-with-random-allowed-to-fail-codes failed to run.
╰─▶ Process ./fail.sh failed: exit code 80
Error: Process completed with exit code 1.
Expected vs actual
- Expected:
moon run exits with the wrapped task's exit code — 80 here — as the script does when
run directly, and as task runners like make, npm run, and just propagate a wrapped command's
code. The pipeline then allow-lists 80 and the job is a tolerated failure.
- Actual:
moon run exits 1, whichever of 70 / 80 / 90 the script exits. moon's own
diagnostic reports the real code — Process ./fail.sh failed: exit code 80 — yet the process
returns 1. The allow-list never matches, so a tolerated code is indistinguishable from a
genuine failure and the job goes red.
Root cause
moon run delegates to exec:
|
pub async fn run(session: MoonSession, args: RunArgs) -> SessionResult { |
|
exec(session, { |
|
let mut exec = args.to_exec_args(); |
|
args.apply_affected_to_exec_args(&mut exec); |
|
|
|
exec.targets = args.targets; |
|
exec.on_failure = OnFailure::Bail; |
|
exec.query = args.query; |
|
exec |
|
}) |
|
.await |
|
} |
exec returns ExecWorkflow::execute's result as the session's exit code:
|
let executor = ExecWorkflow::new(session, args, plan)?; |
|
let exit_code = executor.execute().await?; |
|
|
|
Ok(exit_code) |
ExecWorkflow::execute returns a hardcoded Some(1) for any failed action that is not
allowFailure, never the task's own code:
|
let failed = results.into_iter().any(|result| { |
|
if result.has_failed() { |
|
!result.allow_failure |
|
} else { |
|
false |
|
} |
|
}); |
|
|
|
if failed { |
|
return Ok(Some(1)); |
|
} |
The returned Option<u8> becomes the process exit code: None → 0, Some(1) → 1. No branch returns
the wrapped task's exit code.
Possible fixes
- Propagate the wrapped command's exit code as
moon run's exit code — at least when a single task
target runs, or when all failed targets share one code.
- Or an opt-in (task option or CLI flag) to surface the underlying exit code.
allowFailure does not cover this use case: it maps a failing task to exit 0, so it can tolerate
a code only by tolerating every failure — the split into allowed codes and job-failing codes is
exactly what it cannot express.
Environment
- moon 2.4.2 (latest release at time of writing), installed via mise (
aqua:moonrepo/moon)
- Linux x86_64 locally;
ubuntu-latest in the CI run above
- Invocation:
moon run <project>:<task>
Related
Use case: we run moon tasks in CI, where the pipeline must treat certain exit codes as allowed
failures and all others as job failures (e.g. GitLab's
allow_failure: exit_codes). That needs thetask's real exit code;
moon runcollapses every failure to1, so exit-code-based job statuscannot be driven through moon.
Steps to reproduce
Reproducer: https://github.com/k0pernikus/moon-run-swallows-exit-codes — a workspace with one task
wrapping a script that exits a random code from
70/80/90(codes a pipeline wouldallow-list), moon pinned to
2.4.2via mise, and a GitHub workflow that runs the task bare.fail.sh:moon.yml:Run the task and inspect the exit code:
Run the script directly for contrast:
CI run showing the same:
https://github.com/k0pernikus/moon-run-swallows-exit-codes/actions/runs/29156794001/job/86555120128
Expected vs actual
moon runexits with the wrapped task's exit code —80here — as the script does whenrun directly, and as task runners like
make,npm run, andjustpropagate a wrapped command'scode. The pipeline then allow-lists
80and the job is a tolerated failure.moon runexits1, whichever of70/80/90the script exits. moon's owndiagnostic reports the real code —
Process ./fail.sh failed: exit code 80— yet the processreturns
1. The allow-list never matches, so a tolerated code is indistinguishable from agenuine failure and the job goes red.
Root cause
moon rundelegates toexec:moon/crates/app/src/commands/run.rs
Lines 25 to 36 in 3198d41
execreturnsExecWorkflow::execute's result as the session's exit code:moon/crates/app/src/commands/exec.rs
Lines 140 to 143 in 3198d41
ExecWorkflow::executereturns a hardcodedSome(1)for any failed action that is notallowFailure, never the task's own code:moon/crates/app/src/commands/exec.rs
Lines 288 to 298 in 3198d41
The returned
Option<u8>becomes the process exit code:None→ 0,Some(1)→ 1. No branch returnsthe wrapped task's exit code.
Possible fixes
moon run's exit code — at least when a single tasktarget runs, or when all failed targets share one code.
allowFailuredoes not cover this use case: it maps a failing task to exit0, so it can toleratea code only by tolerating every failure — the split into allowed codes and job-failing codes is
exactly what it cannot express.
Environment
aqua:moonrepo/moon)ubuntu-latestin the CI run abovemoon run <project>:<task>Related
propagation) on a different code path.