Skip to content

Commit 1cdbc6e

Browse files
authored
feat(runner): add --log-gha for GitHub Actions annotations (#632)
1 parent d77cafe commit 1cdbc6e

10 files changed

Lines changed: 249 additions & 1 deletion

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ BASHUNIT_LOGIN_SHELL= # Default: false (source login shell profile
3939
# Reports
4040
#───────────────────────────────────────────────────────────────────────────────
4141
BASHUNIT_LOG_JUNIT= # JUnit XML report path (e.g., report.xml)
42+
BASHUNIT_LOG_GHA= # GitHub Actions workflow-commands log path (e.g., gha.log)
4243
BASHUNIT_REPORT_HTML= # HTML test report path (e.g., report.html)
4344

4445
#───────────────────────────────────────────────────────────────────────────────

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- `bashunit::spy` accepts an optional exit code or custom implementation function (#600)
77
- Assert functions accept an optional trailing label to override the failure title (#77)
88
- `--fail-on-risky` flag and `BASHUNIT_FAIL_ON_RISKY` env var treat no-assertion tests as failures (#115)
9+
- `--log-gha <file>` flag and `BASHUNIT_LOG_GHA` env var emit GitHub Actions workflow commands so failed, risky and incomplete tests show up as inline PR annotations (#280)
910

1011
### Changed
1112
- Parallel test execution is now enabled on Alpine Linux (#370)

docs/command-line.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ bashunit test tests/ --parallel --simple
6262
| `--output <format>` | Output format (`tap` for TAP version 13) |
6363
| `-w, --watch` | Watch files and re-run tests on change |
6464
| `--log-junit <file>` | Write JUnit XML report |
65+
| `--log-gha <file>` | Write GitHub Actions workflow-commands log |
6566
| `-j, --jobs <N>` | Run tests in parallel with max N concurrent jobs |
6667
| `-p, --parallel` | Run tests in parallel |
6768
| `--no-parallel` | Run tests sequentially |
@@ -358,8 +359,13 @@ bashunit test tests/ --log-junit results.xml
358359
```bash [HTML Report]
359360
bashunit test tests/ --report-html report.html
360361
```
362+
```bash [GitHub Actions]
363+
bashunit test tests/ --log-gha gha.log && cat gha.log
364+
```
361365
:::
362366

367+
The `--log-gha` flag writes GitHub Actions workflow commands (`::error`, `::warning`, `::notice`) for failed, risky and incomplete tests. When streamed to stdout on a runner, they appear as inline annotations in the "Files changed" tab of a pull request.
368+
363369
### Show Output on Failure
364370

365371
> `bashunit test --show-output`

docs/configuration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,23 @@ BASHUNIT_LOG_JUNIT=log-junit.xml
231231
```
232232
:::
233233

234+
## Log GitHub Actions
235+
236+
> `BASHUNIT_LOG_GHA=file`
237+
238+
Write GitHub Actions workflow commands (`::error`, `::warning`, `::notice`) to the given file, so failed, risky and incomplete tests show up as inline annotations in the "Files changed" tab of a pull request.
239+
240+
On a CI runner, stream the generated file to stdout so GitHub parses it:
241+
242+
::: code-group
243+
```bash [Example]
244+
BASHUNIT_LOG_GHA=gha.log
245+
```
246+
```yaml [GitHub Actions workflow]
247+
- run: ./bashunit --log-gha gha.log tests/ || (cat gha.log && exit 1)
248+
```
249+
:::
250+
234251
## Report HTML
235252
236253
> `BASHUNIT_REPORT_HTML=file`

src/env.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ _BASHUNIT_DEFAULT_DEFAULT_PATH="tests"
1414
_BASHUNIT_DEFAULT_BOOTSTRAP="tests/bootstrap.sh"
1515
_BASHUNIT_DEFAULT_DEV_LOG=""
1616
_BASHUNIT_DEFAULT_LOG_JUNIT=""
17+
_BASHUNIT_DEFAULT_LOG_GHA=""
1718
_BASHUNIT_DEFAULT_REPORT_HTML=""
1819

1920
# Coverage defaults (following kcov, bashcov, SimpleCov conventions)
@@ -31,6 +32,7 @@ _BASHUNIT_DEFAULT_COVERAGE_THRESHOLD_HIGH="80"
3132
: "${BASHUNIT_BOOTSTRAP:=${BOOTSTRAP:=$_BASHUNIT_DEFAULT_BOOTSTRAP}}"
3233
: "${BASHUNIT_BOOTSTRAP_ARGS:=${BOOTSTRAP_ARGS:=}}"
3334
: "${BASHUNIT_LOG_JUNIT:=${LOG_JUNIT:=$_BASHUNIT_DEFAULT_LOG_JUNIT}}"
35+
: "${BASHUNIT_LOG_GHA:=${LOG_GHA:=$_BASHUNIT_DEFAULT_LOG_GHA}}"
3436
: "${BASHUNIT_REPORT_HTML:=${REPORT_HTML:=$_BASHUNIT_DEFAULT_REPORT_HTML}}"
3537

3638
# Coverage
@@ -236,6 +238,7 @@ function bashunit::env::print_verbose() {
236238
"BASHUNIT_BOOTSTRAP"
237239
"BASHUNIT_BOOTSTRAP_ARGS"
238240
"BASHUNIT_LOG_JUNIT"
241+
"BASHUNIT_LOG_GHA"
239242
"BASHUNIT_REPORT_HTML"
240243
"BASHUNIT_PARALLEL_RUN"
241244
"BASHUNIT_SHOW_HEADER"

src/main.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ function bashunit::main::cmd_test() {
9696
export BASHUNIT_LOG_JUNIT="$2"
9797
shift
9898
;;
99+
--log-gha)
100+
export BASHUNIT_LOG_GHA="$2"
101+
shift
102+
;;
99103
-r | --report-html)
100104
export BASHUNIT_REPORT_HTML="$2"
101105
shift
@@ -692,6 +696,10 @@ function bashunit::main::exec_tests() {
692696
bashunit::reports::generate_junit_xml "$BASHUNIT_LOG_JUNIT"
693697
fi
694698

699+
if [ -n "$BASHUNIT_LOG_GHA" ]; then
700+
bashunit::reports::generate_gha_log "$BASHUNIT_LOG_GHA"
701+
fi
702+
695703
if [ -n "$BASHUNIT_REPORT_HTML" ]; then
696704
bashunit::reports::generate_report_html "$BASHUNIT_REPORT_HTML"
697705
fi

src/reports.sh

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ function bashunit::reports::add_test_failed() {
3434

3535
function bashunit::reports::add_test() {
3636
# Skip tracking when no report output is requested
37-
{ [ -n "${BASHUNIT_LOG_JUNIT:-}" ] || [ -n "${BASHUNIT_REPORT_HTML:-}" ]; } || return 0
37+
{
38+
[ -n "${BASHUNIT_LOG_JUNIT:-}" ] ||
39+
[ -n "${BASHUNIT_REPORT_HTML:-}" ] ||
40+
[ -n "${BASHUNIT_LOG_GHA:-}" ]
41+
} || return 0
3842

3943
local file="$1"
4044
local test_name="$2"
@@ -114,6 +118,56 @@ function bashunit::reports::generate_junit_xml() {
114118
} >"$output_file"
115119
}
116120

121+
function bashunit::reports::__gha_encode() {
122+
local text="$1"
123+
# Strip ANSI escape sequences first (one sed call)
124+
text=$(printf '%s' "$text" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g')
125+
# Percent-encode reserved chars per GHA workflow-commands spec.
126+
# Bash 3.0+ parameter expansion avoids extra awk/sed calls.
127+
# Order matters: encode '%' first so the sequences we inject stay literal.
128+
text="${text//%/%25}"
129+
text="${text//$'\r'/%0D}"
130+
text="${text//$'\n'/%0A}"
131+
printf '%s' "$text"
132+
}
133+
134+
function bashunit::reports::generate_gha_log() {
135+
local output_file="$1"
136+
137+
: >"$output_file"
138+
139+
local i
140+
for i in "${!_BASHUNIT_REPORTS_TEST_NAMES[@]}"; do
141+
local file="${_BASHUNIT_REPORTS_TEST_FILES[$i]:-}"
142+
local name="${_BASHUNIT_REPORTS_TEST_NAMES[$i]:-}"
143+
local status="${_BASHUNIT_REPORTS_TEST_STATUSES[$i]:-}"
144+
local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}"
145+
local level="" message=""
146+
147+
case "$status" in
148+
failed)
149+
level="error"
150+
message="$failure_message"
151+
;;
152+
risky)
153+
level="warning"
154+
message="Test has no assertions (risky)"
155+
;;
156+
incomplete)
157+
level="notice"
158+
message="Test incomplete"
159+
;;
160+
*)
161+
continue
162+
;;
163+
esac
164+
165+
local encoded_message
166+
encoded_message=$(bashunit::reports::__gha_encode "$message")
167+
echo "::${level} file=${file},title=${name}::${encoded_message}" >>"$output_file"
168+
done
169+
}
170+
117171
function bashunit::reports::generate_report_html() {
118172
local output_file="$1"
119173

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env bash
2+
3+
function set_up_before_script() {
4+
TEST_ENV_FILE="tests/acceptance/fixtures/.env.default"
5+
TEST_ENV_FILE_BASHUNIT_LOG_GHA="tests/acceptance/fixtures/.env.log_gha"
6+
}
7+
8+
function test_bashunit_when_log_gha_option() {
9+
local test_file=./tests/acceptance/fixtures/test_bashunit_when_log_junit.sh
10+
local log_file
11+
log_file=$(mktemp "${TMPDIR:-/tmp}/bashunit-gha-opt.XXXXXX")
12+
13+
# Inner suite has a failing test, so bashunit exits nonzero; tolerate it
14+
# so the acceptance test keeps running under --strict (set -e).
15+
./bashunit --no-parallel --env "$TEST_ENV_FILE" --log-gha "$log_file" "$test_file" >/dev/null || true
16+
17+
assert_file_exists "$log_file"
18+
assert_contains "::error file=$test_file" "$(cat "$log_file")"
19+
assert_contains "title=Failure" "$(cat "$log_file")"
20+
rm -f "$log_file"
21+
}
22+
23+
function test_bashunit_when_log_gha_env() {
24+
local test_file=./tests/acceptance/fixtures/test_bashunit_when_log_junit.sh
25+
26+
./bashunit --no-parallel --env "$TEST_ENV_FILE_BASHUNIT_LOG_GHA" "$test_file" >/dev/null || true
27+
28+
assert_file_exists log-gha.txt
29+
assert_contains "::error" "$(cat log-gha.txt)"
30+
rm -f log-gha.txt
31+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
BASHUNIT_DEFAULT_PATH=
2+
BASHUNIT_LOG_GHA=log-gha.txt
3+
4+
BASHUNIT_SHOW_HEADER=false
5+
BASHUNIT_HEADER_ASCII_ART=false
6+
BASHUNIT_SIMPLE_OUTPUT=false
7+
BASHUNIT_STOP_ON_FAILURE=false
8+
BASHUNIT_SHOW_EXECUTION_TIME=false
9+
BASHUNIT_VERBOSE=false
10+
BASHUNIT_SHOW_SKIPPED=false
11+
BASHUNIT_SHOW_INCOMPLETE=false
12+
BASHUNIT_FAILURES_ONLY=false

tests/unit/reports_test.sh

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function set_up() {
1818
# Unset report env vars by default
1919
unset BASHUNIT_LOG_JUNIT
2020
unset BASHUNIT_REPORT_HTML
21+
unset BASHUNIT_LOG_GHA
2122

2223
# Create temp file for output tests
2324
_TEMP_OUTPUT_FILE=$(mktemp)
@@ -30,6 +31,7 @@ function tear_down() {
3031
# Restore env vars
3132
unset BASHUNIT_LOG_JUNIT
3233
unset BASHUNIT_REPORT_HTML
34+
unset BASHUNIT_LOG_GHA
3335
}
3436

3537
# Mock functions for report generation tests
@@ -117,6 +119,14 @@ function test_add_test_tracks_when_html_report_enabled() {
117119
assert_same "1" "${#_BASHUNIT_REPORTS_TEST_NAMES[@]}"
118120
}
119121

122+
function test_add_test_tracks_when_gha_log_enabled() {
123+
BASHUNIT_LOG_GHA="gha.log"
124+
125+
bashunit::reports::add_test "file.sh" "test_name" "100" "3" "passed"
126+
127+
assert_same "1" "${#_BASHUNIT_REPORTS_TEST_NAMES[@]}"
128+
}
129+
120130
function test_add_test_populates_all_arrays() {
121131
BASHUNIT_LOG_JUNIT="report.xml"
122132

@@ -305,6 +315,111 @@ function test_generate_report_html_groups_tests_by_file() {
305315
assert_contains '<h2>File: file_b.sh</h2>' "$content"
306316
}
307317

318+
# === GitHub Actions log generation tests ===
319+
320+
function test_generate_gha_log_emits_error_for_failed_test() {
321+
_mock_state_functions
322+
BASHUNIT_LOG_GHA="gha.log"
323+
324+
bashunit::reports::add_test "tests/foo_test.sh" "test_fail" "100" "1" "failed" "expected 1 got 2"
325+
bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE"
326+
327+
local content
328+
content=$(cat "$_TEMP_OUTPUT_FILE")
329+
330+
assert_contains '::error file=tests/foo_test.sh' "$content"
331+
assert_contains 'title=test_fail' "$content"
332+
assert_contains 'expected 1 got 2' "$content"
333+
}
334+
335+
function test_generate_gha_log_emits_warning_for_risky_test() {
336+
_mock_state_functions
337+
BASHUNIT_LOG_GHA="gha.log"
338+
339+
bashunit::reports::add_test "tests/foo_test.sh" "test_risky" "10" "0" "risky"
340+
bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE"
341+
342+
local content
343+
content=$(cat "$_TEMP_OUTPUT_FILE")
344+
345+
assert_contains '::warning file=tests/foo_test.sh' "$content"
346+
assert_contains 'title=test_risky' "$content"
347+
assert_contains 'no assertions' "$content"
348+
}
349+
350+
function test_generate_gha_log_emits_notice_for_incomplete_test() {
351+
_mock_state_functions
352+
BASHUNIT_LOG_GHA="gha.log"
353+
354+
bashunit::reports::add_test "tests/foo_test.sh" "test_incomplete" "0" "0" "incomplete"
355+
bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE"
356+
357+
local content
358+
content=$(cat "$_TEMP_OUTPUT_FILE")
359+
360+
assert_contains '::notice file=tests/foo_test.sh' "$content"
361+
assert_contains 'title=test_incomplete' "$content"
362+
}
363+
364+
function test_generate_gha_log_skips_passed_test() {
365+
_mock_state_functions
366+
BASHUNIT_LOG_GHA="gha.log"
367+
368+
bashunit::reports::add_test "tests/foo_test.sh" "test_ok" "100" "1" "passed"
369+
bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE"
370+
371+
local content
372+
content=$(cat "$_TEMP_OUTPUT_FILE")
373+
374+
assert_empty "$content"
375+
}
376+
377+
function test_generate_gha_log_skips_skipped_test() {
378+
_mock_state_functions
379+
BASHUNIT_LOG_GHA="gha.log"
380+
381+
bashunit::reports::add_test "tests/foo_test.sh" "test_skip" "0" "0" "skipped"
382+
bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE"
383+
384+
local content
385+
content=$(cat "$_TEMP_OUTPUT_FILE")
386+
387+
assert_empty "$content"
388+
}
389+
390+
function test_generate_gha_log_encodes_newlines_in_message() {
391+
_mock_state_functions
392+
BASHUNIT_LOG_GHA="gha.log"
393+
394+
local msg
395+
msg=$(printf 'line one\nline two')
396+
bashunit::reports::add_test "tests/foo_test.sh" "test_multi" "100" "1" "failed" "$msg"
397+
bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE"
398+
399+
local content
400+
content=$(cat "$_TEMP_OUTPUT_FILE")
401+
402+
assert_contains 'line one%0Aline two' "$content"
403+
assert_not_contains 'line one
404+
line two' "$content"
405+
}
406+
407+
function test_generate_gha_log_strips_ansi_color_codes() {
408+
_mock_state_functions
409+
BASHUNIT_LOG_GHA="gha.log"
410+
411+
local msg
412+
msg=$(printf 'expected \033[32mgreen\033[0m got \033[31mred\033[0m')
413+
bashunit::reports::add_test "tests/foo_test.sh" "test_color" "100" "1" "failed" "$msg"
414+
bashunit::reports::generate_gha_log "$_TEMP_OUTPUT_FILE"
415+
416+
local content
417+
content=$(cat "$_TEMP_OUTPUT_FILE")
418+
419+
assert_contains 'expected green got red' "$content"
420+
assert_not_contains $'\033[' "$content"
421+
}
422+
308423
function test_generate_report_html_applies_status_css_classes() {
309424
_mock_state_functions
310425
BASHUNIT_REPORT_HTML="report.html"

0 commit comments

Comments
 (0)