Skip to content

Commit f00bb86

Browse files
Report a non-success outcome when rad commands are killed (radius-project#12759)
Fixes radius-project#12756 ## Problem The `run-rad-commands` composite action seeded its outcome accumulator to `succeeded` and wrote the result file from a `trap write_result EXIT`. Every *other* assignment to `OVERALL_OUTCOME` was a failure value (`command_failed`, `disallowed_command`), each followed by an explicit `exit`. So success was never earned — only seeded. That works for a normal command failure: `record()` returns non-zero, a failure branch assigns `command_failed`, and the trap writes the truth. It does **not** work for a *termination*. A step timeout kills bash mid-command, so no failure branch ever runs — but the EXIT trap still fires and writes the stale `succeeded` seed. `publish-deploy-status` then maps that to `RUN_STATE=succeeded`, and a deploy that was killed is published as a successful one. This is worse than the missing-file case the mapping was designed for: `unknown` → `in_progress` is the intended "no verdict" sentinel, and the trap defeats it by supplying a confident wrong verdict instead of no verdict. radius-project#12746 adds a `timeout-minutes` bound to this step, which makes the affected path the normal shape of a slow-deploy failure rather than an edge case. ## Fix Invert the default so the accumulator is pessimistic: - Seed `OVERALL_OUTCOME="interrupted"` / `OVERALL_EXIT=1`. - Assign `succeeded` / `0` exactly once, after the command loop completes. Every failure path exits before reaching the promotion, and an abnormal termination never reaches it at all, so a killed run now reports the seed. `publish-deploy-status` already maps unrecognized outcomes to `failed` via its `*)` arm, so the consumer needs no change. The fix lives in the composite action rather than the workflow templates on purpose: templates are copied into user repositories and only update on regeneration, whereas composite actions resolve at the pinned `{{RADIUS_REF}}` and reach existing repositories automatically. **On cancellation:** the issue asked whether a cancelled job reaches the same path. It does — cancellation kills the step identically — but both deploy workflow templates gate `Publish deployed graph and status` on `if: ${{ !cancelled() }}`, so a cancelled job publishes no status at all. There the seed only corrects the uploaded `rad-commands-result` artifact. The comments and README say so explicitly rather than overclaiming. ## Tests New `command-outcome_test.sh` extracts the accumulator prologue *verbatim* from `action.yml` (scoped to the `Run rad commands` step so an unrelated step can't silently redirect the extraction), then models a step timeout faithfully: it forks a subshell with the same options GitHub gives a `shell: bash` step, waits for the trap to be installed, asserts the result file is not written eagerly, and sends a real `SIGTERM`. It then asserts the published outcome is neither `succeeded`/`success` nor `unknown` (which would map to the neutral `in_progress`), plus static checks that `succeeded` is assigned exactly once and after the last failure assignment. Mutation-tested against three regressions — restoring the optimistic seed, removing the promotion, and renaming `command_failed` — each fails with a specific diagnostic. Also adds an `interrupted` → `failed` case to `publish-deploy-status_test.sh`, and wires the new suite into `make test` as `test-command-outcome`. Validation: both suites pass; `shellcheck` 0.11.0 with the repo rcfile is clean; both `action.yml` files parse. ## Docs `.github/extension/README.md` now documents the pessimistic-accumulator contract next to the `rad-commands-result` output description, including the cancellation caveat. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 40a045b commit f00bb86

7 files changed

Lines changed: 319 additions & 6 deletions

File tree

‎.github/extension/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ Triggers and permissions live on the **dispatcher** (`run-rad-commands.yml`); th
140140
| `rad_commands` | No | A single `rad` command string, or a JSON array of command strings run in order (the `rad` prefix omitted, e.g. `["deploy .radius/app.bicep --environment dev", "app graph my-app -o json"]`). Each command is validated against the allowed-command set. Falls back to the `RADIUS_RAD_COMMANDS` variable. When empty, the workflow runs its default `rad deploy` of the app bicep. |
141141

142142
- **Outputs:** a combined `rad-commands-result` artifact — a JSON document with a top-level `outcome`/`exitCode` and a `commands` array (one entry per command, in input order, with each command's exit code and output).
143+
144+
The top-level `outcome` accumulator is pessimistic. It is seeded to `interrupted` and promoted to `succeeded` only once the command loop completes, and the promotion is guarded so it can only ever run from that seed. Every failure path assigns its own value (`command_failed`, `disallowed_command`) and exits. The result file is written by an `EXIT` trap, so any exit before the promotion publishes the seed rather than an unearned success. The reachable case today is an `errexit` abort — GitHub runs `shell: bash` steps as `bash --noprofile --norc -eo pipefail {0}`, so an unguarded non-zero command ends the step without any failure branch running — and the same holds for a step timeout or a cancellation. `publish-deploy-status` maps `interrupted`, like any other unrecognized outcome, to a failed run state. On a cancelled job the deploy workflows gate `Publish deployed graph and status` on `!cancelled()`, so no status is published at all; the corrected outcome then survives only in the `rad-commands-result` artifact, and only when the upload — which shares the step's deadline — still gets to run. A `SIGKILL` runs no trap and so produces no artifact.
143145
- **Permissions:** `id-token: write` (required for OIDC), `contents: write` (so `rad shutdown` can push the `radius-state` branch when the git state backend is selected), and `packages: write` (to push the OCI-backed state archive to GHCR and the application image built by the containerImages recipe).
144146

145147
### Required environment variables

‎.github/extension/actions/publish-deploy-status/action.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,9 @@ runs:
119119
# makes `in_progress` reachable today, on a run that has already finished:
120120
# it means "no verdict", not "still running". Real
121121
# non-success outcomes from run-rad-commands (command_failed,
122-
# disallowed_command) must still map to failed, or a broken deploy would be
123-
# reported as merely unfinished.
122+
# disallowed_command, and the `interrupted` seed left behind by a step that
123+
# exited abnormally before completing) must still map to failed, or a
124+
# broken deploy would be reported as merely unfinished.
124125
case "$OUTCOME" in
125126
succeeded|success) RUN_STATE="succeeded" ;;
126127
unknown) RUN_STATE="in_progress" ;;

‎.github/extension/actions/publish-deploy-status/publish-deploy-status_test.sh‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,22 @@ assert_run_state "failed"
598598
assert_status_file_contains "deploy-state.txt" "state=failed"
599599
assert_status_file_contains "deploy-state.txt" "exitCode=1"
600600

601+
# ---------------------------------------------------------------------------
602+
# A run killed mid-command by a step timeout is reported by run-rad-commands
603+
# as its pessimistic seed. That outcome is not one this action names
604+
# explicitly, so the `*)` arm must catch it as failed - reporting it as
605+
# in_progress would repeat the bug the seed exists to prevent. (Cancellation
606+
# kills the step the same way, but the workflows skip publishing entirely on
607+
# `!cancelled()`, so it does not reach this mapping.)
608+
# ---------------------------------------------------------------------------
609+
reset_environment
610+
printf '{"outcome":"interrupted","exitCode":1}\n' >"${RESULT_FILE}"
611+
run_publisher
612+
assert_exit_zero "interrupted deploy"
613+
assert_progress_contract
614+
assert_run_state "failed"
615+
assert_status_file_contains "deploy-state.txt" "state=interrupted"
616+
601617
# ---------------------------------------------------------------------------
602618
# An unreachable control plane must still produce a well-formed progress file
603619
# rather than a malformed one the canvas cannot parse.

‎.github/extension/actions/run-rad-commands/action.yml‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,21 @@ runs:
171171
# Aggregate result accumulators. The result is written by a trap on EXIT so
172172
# the combined `rad-commands-result` artifact is complete even when a command
173173
# fails and the step exits early.
174+
#
175+
# The accumulator is pessimistic: it is seeded to `interrupted` and only
176+
# promoted to `succeeded` once the command loop has actually completed, so
177+
# any exit before that point reports the seed rather than an unearned
178+
# success. That covers every abnormal exit that still runs the trap: most
179+
# reachably an `errexit` abort, since GitHub runs `shell: bash` steps as
180+
# `bash --noprofile --norc -eo pipefail {0}`, and equally a step timeout
181+
# (see #12746, which adds that bound) or a cancellation.
182+
# `publish-deploy-status` maps unrecognized outcomes to `failed` via its
183+
# `*)` arm, so `interrupted` lands correctly there. On a cancelled job the
184+
# workflow templates gate publishing on `!cancelled()`, so no status is
185+
# published and only the artifact carries the corrected outcome.
174186
COMMANDS_JSON='[]'
175-
OVERALL_OUTCOME="succeeded"
176-
OVERALL_EXIT=0
187+
OVERALL_OUTCOME="interrupted"
188+
OVERALL_EXIT=1
177189
REQUESTED=0
178190
RAN=0
179191
OIDC_REFRESH_PID=""
@@ -515,6 +527,19 @@ runs:
515527
echo "✅ Deployment complete."
516528
fi
517529
530+
# Earn the success verdict. Promote only from the seed, so an outcome a
531+
# failure path recorded is never overwritten - the failure paths above all
532+
# exit first today, but the promotion does not depend on them continuing
533+
# to. An abnormal exit never reaches here at all.
534+
if [ "$OVERALL_OUTCOME" = "interrupted" ]; then
535+
OVERALL_OUTCOME="succeeded"
536+
OVERALL_EXIT=0
537+
fi
538+
# Exit on the accumulator so the step result and the artifact agree. Without
539+
# this the block would end on a successful assignment and report success even
540+
# when the accumulator holds a failure.
541+
exit "$OVERALL_EXIT"
542+
518543
- name: Upload command result
519544
if: always()
520545
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
#!/bin/bash
2+
3+
# Tests the result-accumulator lifecycle in the run-rad-commands composite
4+
# action: the outcome written by the EXIT trap must never claim success for a
5+
# run that exited before it completed.
6+
#
7+
# Any exit before the promotion bypasses the failure branches that assign
8+
# `command_failed` - but the EXIT trap still fires and writes the accumulator as
9+
# it stands. GitHub runs `shell: bash` steps as `bash --noprofile --norc -eo
10+
# pipefail {0}`, so the reachable case is an `errexit` abort; a step timeout or
11+
# a cancellation behaves the same way. The accumulator must therefore be
12+
# pessimistic: seeded to a non-success value and promoted to `succeeded` only
13+
# after the command loop has actually completed.
14+
15+
set -euo pipefail
16+
17+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18+
readonly SCRIPT_DIR
19+
# Overridable so the suite can be pointed at a mutated copy of the action to
20+
# confirm the static assertions below actually bite.
21+
ACTION_FILE="${ACTION_FILE:-${SCRIPT_DIR}/action.yml}"
22+
readonly ACTION_FILE
23+
readonly STEP_NAME="Run rad commands"
24+
# The step sources this before the accumulator, and `cleanup` calls into it, so
25+
# the extracted prologue needs it too. Sourced for real rather than stubbed, so
26+
# a change to those helpers is exercised here instead of hidden behind a stub.
27+
readonly PROGRESS_LIB="${SCRIPT_DIR}/../deploy-progress/progress.sh"
28+
# The value the accumulator is seeded to, and the value the promotion is allowed
29+
# to promote *from*. Asserted exactly rather than as "anything non-success":
30+
# publish-deploy-status, the design note, and the promotion guard all name it.
31+
readonly SEED_OUTCOME="interrupted"
32+
readonly SEED_EXIT="1"
33+
TEST_ROOT="$(mktemp -d)"
34+
readonly TEST_ROOT
35+
trap 'rm -rf "${TEST_ROOT}"' EXIT
36+
37+
readonly PROLOGUE="${TEST_ROOT}/prologue.sh"
38+
readonly RESULT_FILE="${TEST_ROOT}/rad-commands-result.json"
39+
readonly READY_FILE="${TEST_ROOT}/ready"
40+
readonly VICTIM_LOG="${TEST_ROOT}/victim.log"
41+
42+
fail() {
43+
echo "FAIL: $*" >&2
44+
exit 1
45+
}
46+
47+
if ! command -v jq >/dev/null 2>&1; then
48+
fail "jq is required; run 'make install-jq'"
49+
fi
50+
if ! command -v python3 >/dev/null 2>&1; then
51+
fail "python3 is required to parse action.yml"
52+
fi
53+
[[ -f "${PROGRESS_LIB}" ]] ||
54+
fail "expected the deploy-progress helpers at ${PROGRESS_LIB}; the accumulator's EXIT trap calls into them"
55+
56+
# Extract the accumulator prologue verbatim from the action - the seed, the
57+
# write_result writer, and the EXIT trap - so the test exercises the shipped
58+
# code rather than a copy of it that can drift. Scoped to the "Run rad
59+
# commands" step so an unrelated step gaining the same anchor lines cannot
60+
# silently redirect the extraction.
61+
extract_prologue() {
62+
python3 - "${ACTION_FILE}" "${PROLOGUE}" "${STEP_NAME}" <<'PYTHON'
63+
import re
64+
import sys
65+
66+
action_file, out_file, step_name = sys.argv[1], sys.argv[2], sys.argv[3]
67+
lines = open(action_file, encoding="utf-8").read().splitlines()
68+
69+
steps = [i for i, l in enumerate(lines)
70+
if re.match(r"^\s*-\s+name:\s+%s\s*$" % re.escape(step_name), l)]
71+
if len(steps) != 1:
72+
sys.exit("expected exactly one '%s' step, found %d" % (step_name, len(steps)))
73+
74+
# Bound the search at the next step so the prologue can only come from this one.
75+
start_line = steps[0]
76+
step_indent = len(lines[start_line]) - len(lines[start_line].lstrip(" "))
77+
end = len(lines)
78+
for i, line in enumerate(lines[start_line + 1:], start=start_line + 1):
79+
if not line.strip():
80+
continue
81+
indent = len(line) - len(line.lstrip(" "))
82+
if indent <= step_indent and line.lstrip().startswith("- "):
83+
end = i
84+
break
85+
step = lines[start_line:end]
86+
87+
seeds = [i for i, l in enumerate(step) if l.strip().startswith("RESULT_FILE=")]
88+
if len(seeds) != 1:
89+
sys.exit("expected exactly one RESULT_FILE assignment in the step, "
90+
"found %d" % len(seeds))
91+
92+
base = len(step[seeds[0]]) - len(step[seeds[0]].lstrip(" "))
93+
body = []
94+
for line in step[seeds[0]:]:
95+
body.append(line[base:] if line.strip() else "")
96+
# The trap's handler has been renamed before (write_result -> cleanup), so
97+
# match any handler rather than pinning to one name.
98+
if re.match(r"^trap\s+\w+\s+EXIT$", line.strip()):
99+
break
100+
else:
101+
sys.exit("did not find a 'trap <handler> EXIT' after the accumulator seed")
102+
103+
open(out_file, "w", encoding="utf-8").write("\n".join(body) + "\n")
104+
PYTHON
105+
106+
grep -qE '^trap[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]+EXIT$' "${PROLOGUE}" ||
107+
fail "failed to extract the accumulator prologue from action.yml"
108+
109+
# Keep the artifact - and the directory holding it - inside the sandbox
110+
# instead of the runner's /tmp/radius-output.
111+
sed -i -E \
112+
-e "s|^RESULT_FILE=.*|RESULT_FILE=${RESULT_FILE}|" \
113+
-e "s|^mkdir -p /tmp/radius-output\$|mkdir -p ${TEST_ROOT}|" \
114+
"${PROLOGUE}"
115+
grep -q "^RESULT_FILE=${RESULT_FILE}\$" "${PROLOGUE}" ||
116+
fail "failed to redirect RESULT_FILE into the test sandbox"
117+
if grep -q '/tmp/radius-output' "${PROLOGUE}"; then
118+
fail "the extracted prologue still writes to /tmp/radius-output"
119+
fi
120+
}
121+
122+
result_outcome() {
123+
jq -r '.outcome' "${RESULT_FILE}"
124+
}
125+
126+
result_exit_code() {
127+
jq -r '.exitCode' "${RESULT_FILE}"
128+
}
129+
130+
# Locate a line in action.yml. Fails with a useful message rather than handing
131+
# an empty operand to a later comparison if the pattern has been renamed away.
132+
action_line() {
133+
local pattern="$1" picker="$2" line
134+
line="$(grep -n -e "${pattern}" "${ACTION_FILE}" | "${picker}" -1 | cut -d: -f1)" ||
135+
fail "no line in action.yml matches: ${pattern}"
136+
[[ -n "${line}" ]] || fail "no line in action.yml matches: ${pattern}"
137+
printf '%s' "${line}"
138+
}
139+
140+
extract_prologue
141+
142+
# ---------------------------------------------------------------------------
143+
# A run that exits mid-command must not publish success.
144+
#
145+
# Models an abnormal exit faithfully: the prologue runs, a command is in flight,
146+
# and the shell dies without any failure branch executing - only the EXIT trap
147+
# gets to speak. SIGTERM stands in for the whole class (step timeout, job
148+
# cancellation); the `errexit` case is covered separately below. The subshell
149+
# uses the same options GitHub gives a `shell: bash` step (`-e -o pipefail`).
150+
# ---------------------------------------------------------------------------
151+
# The victim's own output is not asserted on, so send it to a log rather than
152+
# inheriting this script's stdout. SIGTERM reaps the subshell but not the
153+
# `sleep` it is waiting on, and an orphan holding the pipe open would stall any
154+
# caller that captures our output until the sleep expires.
155+
# RUNNER_TEMP keeps the progress helpers' scratch files inside the sandbox.
156+
ENVIRONMENT="aks-dev" RUNNER_TEMP="${TEST_ROOT}" bash -eo pipefail -c "
157+
source '${PROGRESS_LIB}'
158+
source '${PROLOGUE}'
159+
touch '${READY_FILE}'
160+
# Stands in for a long-running \`rad deploy\`.
161+
sleep 60
162+
" >"${VICTIM_LOG}" 2>&1 &
163+
victim=$!
164+
165+
# Kill only once the trap is installed, so the test cannot race ahead of the
166+
# code it is exercising and mistake a never-started run for a killed one.
167+
for _ in $(seq 1 100); do
168+
[[ -f "${READY_FILE}" ]] && break
169+
sleep 0.1
170+
done
171+
[[ -f "${READY_FILE}" ]] ||
172+
fail "the extracted prologue never finished installing the EXIT trap$(printf '\n%s' "$(cat "${VICTIM_LOG}" 2>/dev/null)")"
173+
174+
# The result file is written by the trap, not eagerly. If that ever changes,
175+
# the assertions below would be testing a file the kill had no part in.
176+
if [[ -f "${RESULT_FILE}" ]]; then
177+
fail "the result file must be written by the EXIT trap, not eagerly"
178+
fi
179+
180+
kill -TERM "${victim}" 2>/dev/null || true
181+
wait "${victim}" 2>/dev/null || true
182+
183+
[[ -f "${RESULT_FILE}" ]] ||
184+
fail "expected the EXIT trap to write a result file on termination"
185+
186+
# Assert the seed exactly. `interrupted` is not an arbitrary non-success value:
187+
# publish-deploy-status maps it to `failed` through its `*)` arm, it must not be
188+
# `unknown` (which that action reserves for a missing result file and maps to
189+
# the neutral `in_progress`), and the promotion in action.yml is guarded to fire
190+
# only from this exact value.
191+
outcome="$(result_outcome)"
192+
[[ "${outcome}" == "${SEED_OUTCOME}" ]] ||
193+
fail "a killed run published '${outcome}'; expected the seed '${SEED_OUTCOME}'"
194+
exit_code="$(result_exit_code)"
195+
[[ "${exit_code}" == "${SEED_EXIT}" ]] ||
196+
fail "a killed run published exitCode '${exit_code}'; expected '${SEED_EXIT}'"
197+
198+
# ---------------------------------------------------------------------------
199+
# An `errexit` abort must not publish success either.
200+
#
201+
# This is the reachable case today: GitHub runs `shell: bash` steps as
202+
# `bash --noprofile --norc -eo pipefail {0}`, so any unguarded non-zero command
203+
# ends the step mid-run. No failure branch assigns an outcome, and the trap
204+
# still fires.
205+
# ---------------------------------------------------------------------------
206+
rm -f "${RESULT_FILE}"
207+
ENVIRONMENT="aks-dev" RUNNER_TEMP="${TEST_ROOT}" bash -eo pipefail -c "
208+
source '${PROGRESS_LIB}'
209+
source '${PROLOGUE}'
210+
# Stands in for any unguarded command that returns non-zero.
211+
false
212+
# Unreachable under errexit; present so a broken -e would be visible.
213+
OVERALL_OUTCOME=\"succeeded\"
214+
" && fail "the errexit run exited 0; the shell must abort on the failed command"
215+
216+
[[ -f "${RESULT_FILE}" ]] ||
217+
fail "expected the EXIT trap to write a result file on an errexit abort"
218+
outcome="$(result_outcome)"
219+
[[ "${outcome}" == "${SEED_OUTCOME}" ]] ||
220+
fail "an errexit abort published '${outcome}'; expected the seed '${SEED_OUTCOME}'"
221+
exit_code="$(result_exit_code)"
222+
[[ "${exit_code}" == "${SEED_EXIT}" ]] ||
223+
fail "an errexit abort published exitCode '${exit_code}'; expected '${SEED_EXIT}'"
224+
225+
# The seed must be the value the prologue actually assigns, and only that value,
226+
# so the assertions above cannot drift from the shipped code.
227+
seed_assignments="$(grep -c "^OVERALL_OUTCOME=\"${SEED_OUTCOME}\"$" "${PROLOGUE}" || true)"
228+
[[ "${seed_assignments}" == "1" ]] ||
229+
fail "expected exactly one OVERALL_OUTCOME=\"${SEED_OUTCOME}\" seed in the prologue, found ${seed_assignments}"
230+
231+
# ---------------------------------------------------------------------------
232+
# Success must be earned, not seeded - and the promotion must be guarded.
233+
#
234+
# The failure branches all exit before the promotion today, but the promotion
235+
# does not get to depend on that: it is guarded so it can only ever fire from
236+
# the seed. Assert the guard rather than trying to prove reachability by
237+
# scanning the source, which indentation cannot establish in shell.
238+
# ---------------------------------------------------------------------------
239+
# `grep -c` exits 1 on zero matches, which under `set -e` would abort silently -
240+
# report the count instead so a removed promotion names itself.
241+
success_assignments="$(grep -c '^[[:space:]]*OVERALL_OUTCOME="succeeded"$' "${ACTION_FILE}" || true)"
242+
[[ "${success_assignments}" == "1" ]] ||
243+
fail "expected exactly one OVERALL_OUTCOME=\"succeeded\" assignment, found ${success_assignments}"
244+
245+
seed_line="$(action_line '^[[:space:]]*OVERALL_OUTCOME=' head)"
246+
success_line="$(action_line '^[[:space:]]*OVERALL_OUTCOME="succeeded"$' head)"
247+
last_failure_line="$(action_line '^[[:space:]]*OVERALL_OUTCOME="command_failed"$' tail)"
248+
[[ "${success_line}" != "${seed_line}" ]] ||
249+
fail "OVERALL_OUTCOME is seeded to 'succeeded'; an interrupted run would inherit it"
250+
[[ "${success_line}" -gt "${last_failure_line}" ]] ||
251+
fail "OVERALL_OUTCOME=\"succeeded\" must be assigned after the command loop"
252+
253+
# The promotion fires only from the seed, so an outcome recorded by a failure
254+
# path that did not exit survives instead of being overwritten.
255+
guard_line="$(action_line "^[[:space:]]*if \[ \"\$OVERALL_OUTCOME\" = \"${SEED_OUTCOME}\" \]; then\$" tail)"
256+
[[ "$((success_line - guard_line))" == "1" ]] ||
257+
fail "OVERALL_OUTCOME=\"succeeded\" (line ${success_line}) must be guarded by the seed test on the line above it (found the guard on line ${guard_line})"
258+
259+
# Exit on the accumulator, so the step result cannot disagree with the artifact.
260+
# shellcheck disable=SC2016 # matching the literal text `exit "$OVERALL_EXIT"`
261+
exit_line="$(action_line '^[[:space:]]*exit "\$OVERALL_EXIT"$' tail)"
262+
[[ "${exit_line}" -gt "${success_line}" ]] ||
263+
fail "the run block must end with 'exit \"\$OVERALL_EXIT\"' after the promotion"
264+
265+
echo "run-rad-commands command outcome tests passed"

0 commit comments

Comments
 (0)