Two DevOps engineers debugging a Bash release script that hides a failed command substitution.

Stop Bash local From Hiding a Failed Command

Sat, Oct 17, 2026

candidate=release-A appears in the output. The producer then returns 7. Nevertheless, the function reaches PUBLISH:candidate=release-A, and its process exits zero. The candidate looks reasonable, the publication line looks intentional, and the outer caller receives success. The failure has crossed a boundary that the wrapper never checked.

The decisive line is small: local payload="$(producer)". Reading it as “run the producer and retain its result” overlooks a second operation: declaring a local variable. In the reproduced case, the producer fails while the declaration succeeds. Those results describe different commands, even though the source places them on one line.

Separating declaration from assignment exposes the producer's status, but a release guard still needs an explicit decision. A function that stops under set -e when called directly can continue when the same caller uses it as an if condition. The apparent repair therefore needs testing at both the assignment boundary and the function's invocation boundary.

This playbook follows a synthetic candidate through those boundaries in GNU Bash 5.2.37. PUBLISH is an inert printed sentinel; nothing is deployed. The goal is observable: failed production must preserve code 7 and prevent publication, while successful production must retain the expected text and reach the sentinel exactly once. The evidence distinguishes producer status, command status, function status, and the process result consumed by an outer runner.

Define the publication gate before inspecting syntax

Write the acceptance rule before changing the shell. For this fixture, production has two independent outputs: bytes on stdout and an exit status. The bytes identify a synthetic candidate. The status says whether the producer completed successfully. Neither property substitutes for the other, and the wrapper must examine both before reaching its publication boundary.

Use these operational assertions:

  • A producer returning 7 must make the guarded process return 7, with no publication line and no accepted candidate on stdout.

  • A producer returning zero must still supply exactly candidate=release-A after command substitution.

  • Only an accepted candidate may produce PUBLISH:candidate=release-A, exactly once.

  • A successful producer with an unexpected value must be rejected by the wrapper's separate payload check.

Preserving 7 makes the status path visible. A wrapper could deliberately map failures to a documented generic code, but that would require a different assertion. Here, normalizing a producer failure to zero violates the contract, even when another diagnostic mentions the failure. A consumer that receives zero has no reason, under this contract, to reinterpret a plausible candidate as rejected.

The unsafe witnesses intentionally print publication lines so the defect can be reproduced. Their matching transcripts earn REPAIR, never permission to publish. ACCEPT applies only to the explicit guarded implementation after its positive and negative controls pass. This distinction prevents a green diagnostic suite from being mistaken for approval of every implementation it exercises.

Keep this boundary separate from the separate tee pipeline status problem. Here, the failed command runs inside a declaration argument; there is no pipeline whose aggregate status needs reconstruction. The useful evidence is the declaration result, the function's caller, and whether output crossed the acceptance boundary. Replacing the candidate with a real release identifier would add risk without improving that evidence.

Build a clean Bash harness with an inert producer

Use a local GNU Bash 5.2.37 executable, Python 3, and a new directory containing the three complete files provided here: run.py, gate.bash, and cases.bash. The reproduced run used Bash 5.2.37(1)-release and Python 3.12.14. All reference pages were consulted on October 17, 2026. The maintainer manual is dated April 7, 2025; it supplies language documentation, while 5.2.37 remains this experiment's executed baseline.

Save the following as run.py. The other two files appear with the guard and regression matrix below. Run only after saving all three. No package installation, credentials, remote execution, or system configuration change is part of this fixture.

The runner invokes Bash with an argument vector and a script filename. Its version check precedes the matrix, so a mismatch produces RERUN instead of results silently attributed to the requested baseline. Python supplies observation and isolation; Bash executes every shell witness.

#!/usr/bin/env python3
import json
from pathlib import Path
import subprocess
import sys
import tempfile

root = Path(__file__).resolve().parent
if len(sys.argv) != 2:
    raise SystemExit("usage: python3 run.py /absolute/path/to/bash")
bash = Path(sys.argv[1])
if not bash.is_absolute() or not bash.is_file():
    raise SystemExit("supply an existing absolute Bash executable path")

records, errors = [], []
candidate = "candidate=release-A\n"
payload = "PAYLOAD:" + candidate
publication = "PUBLISH:" + candidate
rejection = "REJECT:producer-status=7\n"

with tempfile.TemporaryDirectory(prefix="bash-local-gate-") as temporary:
    work = Path(temporary)
    home = work / "home"
    home.mkdir()
    clean_env = {"PATH": "/usr/bin:/bin", "HOME": str(home), "LC_ALL": "C"}

    def run(name, control="failure"):
        number = len(records)
        out_path, err_path = work / f"{number}.out", work / f"{number}.err"
        argv = [str(bash), "--noprofile", "--norc",
                str(root / "cases.bash"), name, control]
        with out_path.open("wb") as out, err_path.open("wb") as err:
            process = subprocess.run(
                argv, cwd=root, env=clean_env, stdin=subprocess.DEVNULL,
                stdout=out, stderr=err, timeout=10, check=False)
        record = {"case": name, "control": control, "argv": argv,
                  "process_status": process.returncode,
                  "stdout": out_path.read_bytes().decode("utf-8"),
                  "stderr": err_path.read_bytes().decode("utf-8")}
        records.append(record)
        return record

    def check(record, expected_status, expected_out, expected_err,
              expected_unsafe=False):
        observed = (record["process_status"], record["stdout"], record["stderr"])
        expected = (expected_status, expected_out, expected_err)
        label = record["case"] + "/" + record["control"]
        if observed != expected:
            errors.append(label + ": transcript/status mismatch")
        if record["control"] != "success" and "PUBLISH:" in record["stdout"]:
            if not expected_unsafe:
                errors.append(label + ": publication after producer failure")

    version = run("version")
    if (version["process_status"] != 0 or version["stderr"] or
            not version["stdout"].startswith("5.2.37(")):
        raise SystemExit("RERUN: exact Bash 5.2.37 baseline required")

    for control, status, output in [
            ("failure", 7, candidate), ("success", 0, candidate), ("empty", 7, "")]:
        check(run("producer", control), status, output, "")

    # Ten baseline cases. Unsafe witnesses are explicit REPAIR controls.
    check(run("plain"), 7, payload, "COMMAND:7\nFUNCTION:7\n")
    for name in ["local", "declare", "export", "readonly", "local-e"]:
        check(run(name), 0, payload + publication,
              "COMMAND:0\nFUNCTION:0\n", expected_unsafe=True)
    check(run("split-direct"), 7, "", "")
    check(run("split-if"), 0, payload + publication,
          "COMMAND:7\nFUNCTION:0\n", expected_unsafe=True)
    check(run("guard-if-on"), 7, "", rejection + "FUNCTION:7\n")
    check(run("negated"), 0, payload, "NEGATED:0\nFUNCTION:0\n")

    # Extensions: all four caller/errexit combinations, three producer controls.
    for name in ["guard-direct-off", "guard-direct-on", "guard-if-off", "guard-if-on"]:
        for control in ["failure", "success", "empty"]:
            if name == "guard-if-on" and control == "failure":
                continue  # Already recorded in the baseline.
            status = 0 if control == "success" else 7
            output = publication if control == "success" else ""
            diagnostics = "" if control == "success" else rejection
            if not (name == "guard-direct-on" and status == 7):
                diagnostics += f"FUNCTION:{status}\n"
            check(run(name, control), status, output, diagnostics)

    report = {"bash": version["stdout"].strip(), "environment": clean_env,
              "records": records, "errors": errors}
    (root / "evidence.json").write_text(json.dumps(report, indent=2) + "\n")

if errors:
    for error in errors:
        print("FAIL:" + error, file=sys.stderr)
    raise SystemExit(1)
print("WITNESSES:10 matched; unsafe paths remain REPAIR")
print("ACCEPT:guard passed 12 caller/producer controls")
print("EVIDENCE:evidence.json")

The runner supplies a fresh environment containing only a controlled PATH, temporary HOME, and LC_ALL=C. Consequently, inherited BASH_ENV, SHELLOPTS, BASHOPTS, exported functions, and unrelated environment settings do not enter the child environment. The explicit executable path also avoids silently selecting another Bash through PATH. Each case starts a separate noninteractive process with --noprofile --norc.

Startup flags alone are insufficient isolation: the Bash startup rules also describe noninteractive BASH_ENV loading. Clearing the environment addresses that additional input. This is a Bash fixture, not a claim of portable POSIX sh behavior.

The fixed working directory merely lets the runner locate its owned files. For a different boundary involving independently executed recipe lines, see Make recipe shell-context validation.

Record the commands whose status you are measuring

Keep four distinct ledger fields. Producer status comes from standalone controls; assignment or declaration status comes from the immediately saved value; function status comes from its caller when that recorder is reached; process status comes from Python's child-process result.

The saved report retains stdout and stderr separately. COMMAND and FUNCTION are trace labels, while NEGATED identifies a different measured expression. Never fill a missing observation with an assumed capture. For a completed run, temporary capture files are removed after their contents enter evidence.json; that report remains available for review.

Establish a plain assignment as the comparison case

Start with an assignment that has no declaration command attached. The fixture's plain branch performs the following operations inside its witness function, with errexit disabled:

payload="$(producer)"
rc=$?
printf 'COMMAND:%s\n' "$rc" >&2
printf 'PAYLOAD:%s\n' "$payload"
return "$rc"

The independently executed producer control prints candidate=release-A followed by a newline and returns 7. The plain assignment captures the candidate text and exposes the same nonzero result. Its caller then saves the function result before printing any trace and exits with that saved value.

Recorded stdout:

PAYLOAD:candidate=release-A

Recorded stderr:

COMMAND:7
FUNCTION:7

The outer process returns 7. There is no PUBLISH line. These are separate observations: the presence of a payload does not turn the process result into success, and the nonzero result does not imply that stdout was empty.

Checking the string first would reverse the intended decision order. A format check could approve these exact bytes while overlooking the producer's unsuccessful completion. Keep the saved status beside the candidate during review, and make the unsuccessful result sufficient to prevent acceptance regardless of how familiar the text looks.

The Bash manual's simple-command rules specify that an assignment-only command with command substitution takes the last substitution's status. The same reference explains that ordinary command substitution removes trailing newlines. The missing newline in the stored value is therefore expected transformation, not a failure detector.

This witness uses one substitution, one ordinary variable, and one producer with an explicit final return. That deliberately small arrangement makes the comparison interpretable. A reader can compare the immediate status of the assignment with the standalone producer result without introducing another command whose outcome might compete for the same status slot.

The trace's PAYLOAD line is diagnostic output from an unsafe or comparative witness. It is not the guarded function's output contract. The eventual guarded function keeps rejected candidates off stdout entirely. Maintaining that distinction allows the experiment to display the misleading data without teaching the production wrapper to emit data before acceptance.

Notice the explicit final return "$rc". Without it, a successful diagnostic print could become the function's final result. Exposing a failure is only the first step; the function must also return it. The plain case establishes that Bash has made code 7 available at a usable boundary. The next case shows how a declaration can replace that boundary's result.

Reproduce the local declaration that returns success

Inside a function, combine the declaration and substitution, then capture the result immediately:

local payload="$(producer)"
rc=$?

The reproduced local case stores the same candidate text as the plain assignment. Its immediate COMMAND trace, however, is zero. The witness continues to print the payload and publication sentinel, returns zero from its final successful print, and lets the outer process exit zero.

Recorded stdout:

PAYLOAD:candidate=release-A
PUBLISH:candidate=release-A

Recorded stderr:

COMMAND:0
FUNCTION:0

The producer control still returns 7; the process result here is 0. The changed syntax has altered which command supplies the status that the wrapper observes. The output has not become more trustworthy. The declaration successfully creates and initializes the local variable using text that a failed producer already emitted.

The immediate capture is essential to that conclusion. If a diagnostic ran between the declaration and rc=$?, its successful status could produce the same zero without proving anything about local. This fixture saves the declaration result first, making the recorded zero evidence about the intended command rather than the tracing machinery.

ShellCheck SC2155 documents this declaration-and-assignment masking pattern and recommends separating the two operations. Its diagnostic points to the obscured result; operational handling must still decide what to do with that result once exposed.

Do not summarize the observation as “local always succeeds.” The documented builtin contract includes errors such as invalid use, invalid names, and readonly conflicts; invalid options can also fail. This fixture uses a valid name inside a function without conflicting attributes.

That qualification matters during repair. Introducing a deliberate declaration error might produce a nonzero status, but it would test the wrong failure. The operational question is whether a successful declaration preserves the producer's unsuccessful result. This witness answers that question directly: it does not.

Compare related declaration builtins without generalizing blindly

Run these combined forms as separate cases:

declare payload="$(producer)"
export payload="$(producer)"
readonly payload="$(producer)"

Each reproduced case reports a declaration status of zero, reaches one publication sentinel, and exits zero. Each also uses the same producer contract: plausible stdout followed by code 7. The four declaration witnesses therefore share an observed masking outcome under the recorded conditions.

Fresh processes are essential to this comparison. A readonly attribute established by one case must not become the input condition for the next. Likewise, an exported value must not carry into another child's initial environment. Otherwise, a declaration error or inherited value could obscure the boundary the experiment intends to measure.

These observations cover the exact forms shown. They do not establish identical behavior for every declaration option, variable attribute, or shell implementation. Keep the remediation tied to the producer's assignment status, rather than choosing another declaration spelling that happens to pass one local test.

Demonstrate why set -e misses the combined form

The local-e witness changes one condition: it enables set -e before directly invoking the combined-declaration function. The producer and declaration remain identical. The resulting stdout still contains the payload and PUBLISH:candidate=release-A; stderr still records COMMAND:0 and FUNCTION:0; the process still returns zero.

The decisive fact is the zero status of the declaration command. At that boundary, there is no nonzero command result for the intended implicit exit behavior to act on. The producer's 7 has already been masked by the successful declaration. Adding errexit around the same grammar does not restore the missing result.

This observation is narrower than declaring set -e useless. The next direct-call control will demonstrate a situation in which it does stop execution. The defect here is the choice of the command whose status represents production success. A release gate cannot treat successful variable declaration as evidence that the value-generating operation succeeded.

Read the trace in order of responsibility. The producer emits candidate bytes and returns 7. The enclosing declaration returns zero. The function reaches its final print and returns zero. The outer process receives that function result and exits zero. The outer runner faithfully records success for the process it executed; it has not inspected the hidden producer result.

The producer's explicit return 7 also keeps this case focused. There is no uncertainty about whether an earlier command inside the producer should have stopped execution. The failure is deliberately the producer's final result. Changing other shell options would answer a different question and weaken the controlled comparison.

Keep that producer unchanged between comparisons. Replacing it with a silent failure would remove the plausible candidate that makes an unchecked publication path operationally misleading.

During review, ask which command must fail to prevent publication. If the answer is “the declaration must fail,” the gate has attached its decision to the wrong operation. The requirement concerns production, so production needs its own observable status and a branch that rejects it.

Keep the local-e witness in regression evidence even after repairing the guard. It demonstrates why enabling an option was insufficient for this exact defect. If someone later recombines the declaration and assignment while retaining set -e, the failing-producer assertion should expose the resulting publication immediately.

Split declaration and assignment, then test the caller

Separate the operations while retaining the deliberately unsafe continuation:

split_witness() {
  local payload rc
  payload="$(producer)"
  rc=$?
  printf 'COMMAND:%s\n' "$rc" >&2
  printf 'PAYLOAD:%s\n' "$payload"
  printf 'PUBLISH:%s\n' "$payload"
}

With set -e enabled and split_witness invoked directly, the reproduced process stops at the failing assignment and exits 7. No payload trace, publication line, or caller's FUNCTION marker appears. That result establishes useful behavior for this particular caller, but it does not establish an explicit failure policy within the function.

Now change only the invocation to an if condition. The same function reaches its status recorder, which reports COMMAND:7, and then prints both the payload and publication sentinel. The final print succeeds, the function satisfies the caller's condition, and the outer process exits zero.

The second trace is especially useful because the assignment's nonzero result is visible. Splitting the declaration repaired the original masking boundary. Publication still occurred because the function did not use the exposed failure to reject the candidate. The available status and the acceptance decision remain separate operations.

Avoid changing the direct-call experiment merely to collect a convenient status. Wrapping it in if split_witness would create the conditional experiment instead. For the direct errexit case, the external process result is the available observation, and the absent internal markers must remain absent in the ledger.

Why a conditional function can defeat an implicit repair

ShellCheck SC2310 identifies functions invoked in conditional contexts where reliance on errexit is misleading. The Bash reference states that commands within a function executing where -e is ignored are unaffected by that setting. Here, the relevant context is simply the function used as the condition of if.

The caller is entitled to test a function's result. That ordinary interface choice should not silently change whether the function consumes a failed candidate. An explicit guard gives the function its own answer: a failed assignment leads to a saved failure status and a return before publication.

A two-line declaration-and-assignment fix is therefore necessary cleanup for the combined form, but it is incomplete release evidence. The review needs the function's actual callers, including conditional use. A test that runs only the function body in isolation can miss the context that changes the observed outcome.

Review call-site changes with the same care as changes inside the function. Moving an existing direct call into if may look like a reporting improvement: the caller wants to announce success or handle failure. Yet the split witness shows that this edit can also change whether its body reaches publication. A regression tied only to the function's unchanged text would miss that distinction.

Record the two experiments together: direct call, process 7, no sentinel; conditional call, process 0, one sentinel despite assignment 7. That pair explains why the repair must contain a branch. It also gives future reviewers a precise reason to preserve the conditional negative control rather than dismissing it as a redundant execution mode.

Use an explicit assignment guard before consuming output

Save this complete file as gate.bash. It defines the fixed failing producer, separate positive and empty-output controls, and the guarded function. The test dispatcher supplies invocation; sourcing this file alone does not publish anything.

#!/usr/bin/env bash
# Inert gate: no deployment or external-state changes.
producer() {
  printf 'candidate=release-A\n'
  return 7
}

success_producer() {
  printf 'candidate=release-A\n'
  return 0
}

empty_failure_producer() {
  return 7
}

release_candidate() {
  local payload rc
  if payload="$(producer)"; then
    if [[ "$payload" != 'candidate=release-A' ]]; then
      printf 'REJECT:unexpected-payload\n' >&2 || :
      return 65
    fi
    printf 'PUBLISH:%s\n' "$payload"
  else
    rc=$?
    printf 'REJECT:producer-status=%s\n' "$rc" >&2 || :
    return "$rc"
  fi
}

The function declares payload and rc before any operation whose failure it intends to preserve. Its condition is an ordinary assignment. On failure, the first command in else saves $?; the diagnostic uses the saved value, and return sends that same value to the caller. No candidate is printed from the failure branch.

The successful branch performs a second, independent check: the captured string must equal the fixture's expected candidate. A zero producer result permits validation; it does not approve arbitrary output. The chosen value is a synthetic identifier, and code 65 is this wrapper's chosen rejection code for unexpected payloads. Neither is an external deployment convention.

Only after those checks does the function print its inert publication sentinel. Its final print supplies the success-path function result, so a failing sentinel print is not deliberately overwritten by an unconditional return 0. Writable stdout and stderr are part of the controlled run; output-device faults are outside this matrix.

The rejection diagnostic is explicitly best effort: || : prevents a diagnostic failure from replacing the saved producer result before the return. This is a wrapper policy about reporting, not permission to ignore production failure. The producer's unsuccessful status remains the function's result even though the wrapper attempts to explain it.

The file contains no implicit dependence on set -e. The caller may enable it, leave it disabled, or use the function as an if condition. The failure branch remains an explicit return before consumption. That claim will be tested across the specified callers, rather than inferred from the appearance of the code.

The caller must preserve that returned failure too. The dispatcher saves it immediately in its failure branch and exits with the saved value after reporting.

The control helpers exist only to substitute known producer behavior during the experiment. They provide the same plausible text with success, or no text with failure. Neither changes the guarded function. This keeps the positive and negative assertions attached to one implementation instead of maintaining separate “safe” functions whose behavior could diverge.

Test the guard under both direct and conditional invocation

The executed guard matrix crosses two caller forms with two errexit settings and three producer controls. The conditional failure with errexit enabled also occupies one row of the ten-case baseline; it is not counted twice as a separate observation. All twelve guarded combinations were executed in fresh Bash 5.2.37 processes.

Invocation

set -e

Failing producer

Successful producer

Empty-output failure

Direct

Off

7; no publication

0; one publication

7; no publication

Direct

On

7; no publication

0; one publication

7; no publication

if condition

Off

7; no publication

0; one publication

7; no publication

if condition

On

7; no publication

0; one publication

7; no publication

For every failure, stdout is exactly empty. For every success, stdout is exactly the following line, including its final newline:

PUBLISH:candidate=release-A

These are stronger assertions than searching for a word. They verify the absence of premature candidate output on failure and the preservation of the accepted value on success. They also reject duplicate publication and extra stdout that might confuse a downstream consumer.

The plausible-output failure control disproves the shortcut of rejecting only empty candidates; the empty-output failure control confirms that failure remains rejected when no candidate text is emitted. Both failing producers must be rejected, including the one that supplied the expected text. The success control emits that same text with zero status, demonstrating that the guard can accept a legitimate result instead of suppressing publication unconditionally.

A direct errexit-enabled failure stops before the caller can print FUNCTION:7. Its stderr still contains the guard's rejection diagnostic, and Python records process 7. Conditional failures retain the caller marker because the caller's else branch saves the function result and explicitly exits with it. Success in the conditional caller establishes zero by entering then.

Avoid capturing the status of logical negation

The baseline also runs this negative control:

if ! payload="$(producer)"; then
  rc=$?
fi

The recorded value is 0. The assignment failed with the producer's nonzero result, and logical negation made the tested condition successful. Saving $? in that then branch therefore saves the inverted result, not the producer's original code.

The complete witness marks this value NEGATED:0, prints the diagnostic payload, returns zero, and never prints PUBLISH. Its lack of publication does not make it an acceptable failure wrapper: it still loses code 7. A correct gate needs both status preservation and publication suppression.

Use a plain if with an else branch when retaining the original failure matters. Save the status before any report or declaration. Negation remains a distinct tested expression; its success value cannot be relabeled as the assignment's result simply because both appear in the same condition.

Delay export and readonly until after acceptance

Environment values and immutable configuration need the same production boundary. First capture the candidate with a guarded ordinary assignment. Then validate the value. Only after those steps should the wrapper apply export or readonly attributes. An attribute says how a variable will be treated; it does not provide evidence about the command that generated its contents.

The following is a prepared extension, not part of the executed matrix. Insert it inside the successful branch of release_candidate, after payload validation and before its sentinel. Use the owned fixture name in a fresh process:

if export FIXTURE_RELEASE_CANDIDATE="$payload"; then
  :
else
  rc=$?
  printf 'REJECT:export-status=%s\n' "$rc" >&2 || :
  return "$rc"
fi

if readonly FIXTURE_RELEASE_CANDIDATE; then
  :
else
  rc=$?
  printf 'REJECT:readonly-status=%s\n' "$rc" >&2 || :
  return "$rc"
fi

There is no producer call inside either declaration. Their arguments use a value whose production and content checks have already passed. Each declaration also has its own failure branch, because accepting production does not excuse a later preparation error.

If only export is required, keep that step alone; readonly is a separate policy choice. The example applies both to show that each declaration needs its own reviewed failure path.

If export fails, the wrapper reports an export failure and returns that declaration's status. If readonly fails after export, the wrapper reports that later failure and does not reach publication. It must not call either result “producer status”: the producer succeeded earlier. The diagnostic labels identify the operation that actually failed.

These steps are sequential, not transactional. A later attribute failure does not establish that earlier variable changes were undone. The fresh-process fixture contains such state within the child; a reusable wrapper that mutates a longer-lived caller needs a separately documented policy before adoption. The current experiment makes no rollback claim.

For broader context on system administration and automation foundations, retain this ordering as a review habit: acquire, validate, then apply the variable attributes needed by the consumer. The declaration's convenience should not collapse those responsibilities into an unchecked expression.

Before adopting the extension, execute its own success and failure assertions under the actual callers. The passing base guard establishes the producer boundary; it does not certify an unexecuted modification merely because the modification follows that boundary.

Add linting without outsourcing the runtime contract

Linting supplies another review input. SC2155 addresses combined declaration and assignment. SC2310 addresses conditional function invocation with suppressed errexit reliance. SC2312 supplies additional masked-return coverage through an optional check. These diagnostics point to different reasons that a visible command result may fail to represent the operation a reviewer intended to guard.

The two optional check names are check-set-e-suppressed and check-extra-masked-returns. SC2155's documentation specifically notes that default checking omits some local readonly forms, including local -r with substitution. Do not treat the absence of that warning as proof that such a form preserved the producer's status.

The following commands are prepared for a machine with ShellCheck installed; they were not executed for this article. Run them from the fixture directory. The official ShellCheck command reference documents the options and the inherited SHELLCHECK_OPTS input cleared here.

env -u SHELLCHECK_OPTS shellcheck --version > lint-version.txt
if env -u SHELLCHECK_OPTS shellcheck --norc --shell=bash \
  --enable=check-extra-masked-returns,check-set-e-suppressed \
  gate.bash cases.bash > lint-stdout.txt 2> lint-stderr.txt
then
  lint_rc=0
else
  lint_rc=$?
fi
printf '%s\n' "$lint_rc" > lint.rc

The witness file intentionally contains suspect patterns. Review its findings as demonstration evidence, alongside a separate review of the guarded implementation. No diagnostic transcript or clean result is asserted here. The commands record the installed version, selected checks, output streams, and scan status for a later actual run.

If the installed linter rejects a requested option, retain that result as missing coverage. Do not silently remove the option and describe the remaining scan as equivalent. Likewise, a file-processing failure is not a clean scan. Resolve the tool invocation before using its output as review evidence.

Treat lint configuration as evidence, not an oracle

A clean result has meaning only with its inputs: tool version, dialect, enabled checks, configuration, and scanned files. A disabled rule, unsupported form, or different dialect can change what the linter reports without changing the runtime gate's behavior.

Keep those inputs with the regression record. For an intentionally conditional guarded function, review the explicit failure path even if a conditional-errexit warning appears. The runtime acceptance criteria remain code 7 with empty stdout on failure and exact publication on success.

When placing this practice within the broader DevOps toolchain, use linting to direct inspection and executable assertions to decide whether the shell contract holds.

Build a regression matrix that fails closed

Save the complete dispatcher as cases.bash, alongside the two earlier files. It selects fixed modes and literal producer substitutions; it never constructs executable code from external text. The direct calls remain direct, and the conditional calls remain explicit in the source.

#!/usr/bin/env bash
# run.py supplies this file's directory as the working directory.
source ./gate.bash || exit 70
case_name=${1-}
control=${2-failure}

if [[ "$case_name" == version ]]; then
  printf '%s\n' "$BASH_VERSION"
  [[ "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}.${BASH_VERSINFO[2]}" == 5.2.37 ]]
  exit "$?"
fi

# Fixed test substitutions; no externally constructed command strings.
case "$control" in
  failure) ;;
  success) producer() { success_producer; } ;;
  empty) producer() { empty_failure_producer; } ;;
  ) exit 64 ;;
esac

combined_witness() {
  local rc
  case "$case_name" in
    plain)
      payload="$(producer)"
      rc=$?
      printf 'COMMAND:%s\n' "$rc" >&2
      printf 'PAYLOAD:%s\n' "$payload"
      return "$rc"
      ;;
    local|local-e)
      local payload="$(producer)"
      rc=$?
      ;;
    declare)
      declare payload="$(producer)"
      rc=$?
      ;;
    export)
      export payload="$(producer)"
      rc=$?
      ;;
    readonly)
      readonly payload="$(producer)"
      rc=$?
      ;;
  esac
  printf 'COMMAND:%s\n' "$rc" >&2
  printf 'PAYLOAD:%s\n' "$payload"
  printf 'PUBLISH:%s\n' "$payload"
}

split_witness() {
  local payload rc
  payload="$(producer)"
  rc=$?
  printf 'COMMAND:%s\n' "$rc" >&2
  printf 'PAYLOAD:%s\n' "$payload"
  printf 'PUBLISH:%s\n' "$payload"
}

negated_witness() {
  local payload rc
  if ! payload="$(producer)"; then
    rc=$?
    printf 'NEGATED:%s\n' "$rc" >&2
    printf 'PAYLOAD:%s\n' "$payload"
    return "$rc"
  fi
}

case "$case_name" in
  local-e|split-direct|split-if|guard-direct-on|guard-if-on)
    set -e
    ;;
esac

case "$case_name" in
  producer)
    producer
    rc=$?
    exit "$rc"
    ;;
  plain|local|declare|export|readonly|local-e)
    combined_witness
    rc=$?
    ;;
  split-direct)
    split_witness
    rc=$?
    ;;
  split-if)
    if split_witness; then rc=0; else rc=$?; fi
    ;;
  guard-direct-off|guard-direct-on)
    release_candidate
    rc=$?
    ;;
  guard-if-off|guard-if-on)
    if release_candidate; then rc=0; else rc=$?; fi
    ;;
  negated)
    negated_witness
    rc=$?
    ;;
  ) exit 64 ;;
esac
printf 'FUNCTION:%s\n' "$rc" >&2
exit "$rc"

Supply the absolute path of the verified Bash executable. Replace the example path below with that local path; the runner rejects a different version instead of quietly changing the baseline.

python3 run.py /absolute/path/to/bash-5.2.37

The executed ten-case ledger was:

Case

Producer control

Assignment/declaration

Function observation

Process

PUBLISH count

Plain assignment

7

7

7

7

0

Combined local

7

0

0

0

1

Combined declare

7

0

0

0

1

Combined export

7

0

0

0

1

Combined readonly

7

0

0

0

1

Combined local, errexit

7

0

0

0

1

Split, direct, errexit

7

Recorder not reached

Recorder not reached

7

0

Split, conditional, errexit

7

7

0

0

1

Explicit guard, conditional, errexit

7

7

7

7

0

Negated capture

7

7; negation records 0

0

0

0

The producer column uses the independently executed control for the same fixed producer. The negated row's assignment failure follows that contract; its immediate captured value is specifically the negation's zero. In the direct split case, process 7 and missing recorders establish where execution stopped without inventing a separately captured function return.

The runner pins complete stdout, stderr, and process results. Its small explicit allowlist permits only the deliberately unsafe witnesses to reproduce their expected publication. Every unexpected publication after producer failure fails the whole suite; every guarded failure categorically forbids publication. Matching an unsafe witness cannot grant it ACCEPT.

The success and empty-output failure cases are executed extensions. The report contains 25 process records: one version check, three producer controls, ten baseline cases, and eleven additional guard runs. Together with the baseline guard row, those extensions cover twelve guarded combinations.

Recorded runner output:

WITNESSES:10 matched; unsafe paths remain REPAIR
ACCEPT:guard passed 12 caller/producer controls
EVIDENCE:evidence.json

The runner returned zero. Any mismatch returns nonzero and must be investigated before acceptance. Retain evidence.json with the exact fixture files for review. A completed matrix run replaces that report. Preserve an earlier record if comparison matters, and do not treat an existing report as evidence that an interrupted attempt completed.

The report retains each child's exact argument vector, allowing reviewers to confirm the recorded invocation modes.

A missing executable, timeout, or unreadable fixture is an incomplete run. It cannot satisfy the exact transcript checks or justify ACCEPT. Record such conditions as execution problems and rerun after correcting them; do not reinterpret an arbitrary nonzero runner result as successful failure propagation.

Temporary HOME and capture files are removed automatically. To discard the retained report after review, remove only the generated file from the owned fixture directory:

rm -- evidence.json

Choose ACCEPT, REPAIR, RERUN, or HOLD

Use decisions that refer to evidence and implementation, rather than the appearance of a candidate string. A wrapper can produce a convincing release name while failing the gate. Conversely, a diagnostic experiment can return nonzero exactly because it successfully reproduced a failing producer.

Decision

Required evidence or condition

Operational action

ACCEPT

Explicit assignment guard passes failure, success, and empty-output controls under both callers and errexit settings.

Accept the tested shell gate for its recorded contract.

REPAIR

Combined declaration masks failure, implicit handling depends on the caller, or negation loses the original code.

Add explicit checking before consumption and rerun the matrix.

RERUN

Caller coverage is missing, the executable version changes, or relevant wrapper/configuration inputs change.

Reproduce the affected contract with recorded inputs before renewing acceptance.

HOLD

An external producer's stdout or status contract is unknown.

Establish that contract before allowing its output through the gate.

ACCEPT is deliberately bounded. The fixture proves the observed handling of its synthetic candidate and known producer controls under Bash 5.2.37. It does not establish the correctness of a real artifact, a remote publication target, or an untested wrapper modification.

Attach each decision to the wrapper revision, runtime, caller forms, and evidence record that support it. Those details make later revalidation concrete.

HOLD is appropriate when a script emits a release-looking string but its owner cannot explain what nonzero completion means for that string. The wrapper should not invent a success contract from output shape. Likewise, a green CI badge cannot resolve a failure that was already converted into process zero.

Keep diagnostics from overwriting the failure you return

Use the same order at every checked failure boundary:

rc=$?
printf 'REJECT:producer-status=%s\n' "$rc" >&2 || :
return "$rc"

Capture, report, return. A preceding printf, successful declaration, or later test can replace the value exposed by $?. Declare the local rc before the checked operation, then make the plain assignment rc=$? the first command in the failure branch. That ordering preserves the intended status.

The guarded fixture keeps producer diagnostics on stderr rather than mixing them into candidate stdout. The synthetic producer emits none, so the recorded rejection line belongs to the wrapper. Preserve that distinction when adapting the contract to a producer with useful error output.

If a logging operation fails, choose and document its treatment. This wrapper preserves the producer's code and makes rejection reporting best effort. An alternative policy would need different assertions; silently returning the status of whichever print ran last is not an intentional failure policy.

Assign ownership of the shell-to-CI contract

Assign responsibility at the boundary where each owner has evidence. The producer owner specifies what stdout represents and which exit results authorize its use. The wrapper owner decides whether those bytes cross the acceptance boundary. The CI owner ensures that the wrapper's process result reaches the outer job's success or failure decision.

Owner

Contract to maintain

Evidence to review

Producer

Define valid successful output and failure codes, including whether failure can emit plausible data.

Independent successful and failing producer controls.

Shell wrapper

Check production, validate content, suppress rejected output, and preserve the chosen failure code.

Direct and conditional regression runs with exact stream assertions.

CI integration

Preserve the wrapper's nonzero process result at the action or step boundary.

The actual invocation and its later integration test.

GitHub's exit-code documentation describes zero as action success and nonzero as failure. That is the outer contract used here; it is not evidence that a GitHub workflow was run. An outer consumer receiving an incorrectly normalized zero cannot recover the hidden producer failure from that exit code.

The local runner deliberately makes the process boundary visible. It records the Bash child's result without adding a Bash conditional around the function under test. A CI integration should then be reviewed for its own handling of that result. Correctness of the inner guard does not excuse an outer wrapper that catches failure and finishes successfully.

Revalidate when a declaration gains attributes, a caller moves into a conditional, a shell executable changes, or lint configuration changes. Keep the fixture version with the evidence so reviewers can determine whether the recorded result applies to the implementation they are considering. A remembered successful run is weaker than a result tied to concrete inputs.

After establishing this minimal contract, a Kubernetes CI/CD pipeline overview can place the gate within a larger delivery flow. The shell witness remains independently reproducible. Its acceptance does not require an orchestration architecture, and adding one would not repair a declaration that hides the producer's failure.

The handoff is complete only when each owner can state what it accepts and what it returns. That makes a nonzero result actionable without asking another layer to guess what happened inside a function.

Strengthen release automation through structured DevOps practice

The operational habit is to place failure handling at the command whose result is being accepted. A declaration can successfully store rejected data. A function can expose code 7 and then continue to a successful final print. A caller can change the behavior of an implicit errexit assumption. The explicit assignment guard addresses those boundaries with a saved status and a return before consumption.

Keep the small regression exercise available during review. Its strength comes from controlled inputs, a known failure, a positive control, and a publication sentinel whose presence can be counted. Engineers should be able to explain why a candidate was accepted using the recorded status and content checks. That explanation should remain valid when the caller uses if.

For structured study, Refonte Learning's program page lists Linux fundamentals and scripting alongside CI/CD. It describes a three-month commitment at 12–14 hours per week and requires applicants to be working toward a bachelor's or higher-level degree. Those stated topics do not establish a dedicated Bash, ShellCheck, or declaration-status module.

Review the DevOps Engineering Program for its confirmed scripting and CI/CD scope, workload, and admission requirements, and ask whether the current syllabus includes exercises in explicit shell failure handling. The practice to carry forward is concrete: preserve the producer's result, validate its candidate, and permit publication only after both checks pass.