Skip to content

Commit 0a678c6

Browse files
Add shared shell helpers, test framework, and shellcheck config
Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
1 parent 2d1d405 commit 0a678c6

7 files changed

Lines changed: 138 additions & 70 deletions

File tree

CLAUDE.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,43 @@ scripts that set up temporary git repos and validate behavior.
3535
syntax highlighting and testability.
3636
- update CHANGELOG.md with each change. If it's a breaking change, prefix
3737
with "Breaking Change: ". Try to keep change descriptions focused on user
38-
outcome.
38+
outcome. New entries go above older ones (the changelog grows upward).
3939
- CI runs on PRs: `test.yml` runs `./test.sh`, `actionlint.yml` lints workflow YAML files
40+
- Every shellcheck suppression (`disable`, `source`, etc.) must include a short
41+
comment explaining why the suppression is needed. SC1091 (can't follow
42+
sourced file) is disabled globally in `.shellcheckrc` since all sources use
43+
dynamic `$SCRIPT_DIR` paths.
44+
- In test files, prefer `cd "$(dirname "${BASH_SOURCE[0]}")"` at the top and then use literal
45+
relative paths for `source` (e.g., `source ../../actions_helpers.sh`). This
46+
enables IDE go-to-definition via `source-path=SCRIPTDIR` in `.shellcheckrc`
47+
without needing `# shellcheck source=` directives. In production scripts that
48+
cannot `cd`, use `SCRIPT_DIR` with `# shellcheck source=` directives for
49+
navigation.
50+
- `actions_helpers.sh` at the repo root provides shared helpers (`log_error`, `log_warning`,
51+
`log_notice`, `set_output`, `set_output_multiline`). Scripts source it via
52+
a relative path after `cd`-ing to their own directory.
53+
- In shell scripts, prefer long options over short flags for readability
54+
(e.g., `grep --quiet --fixed-strings` instead of `grep -qF`,
55+
`curl --silent --output /dev/null` instead of `curl -s -o /dev/null`).
56+
Exceptions: flags with no long form (e.g., `git checkout -b`) and
57+
universally understood short forms in test helpers (e.g., `rm -rf`).
58+
- Never discard stderr (e.g., `2>/dev/null`) in shell scripts or action
59+
steps. Suppressing stderr hides real errors and makes debugging harder.
60+
Using `2>&1` to merge stderr into stdout is acceptable in test helpers
61+
that need to capture all output for assertion, but avoid it in
62+
production scripts. Run each command on its own line so that `bash -e`
63+
(the default for GitHub Actions `run` steps) halts on failure and the
64+
return code is checked automatically.
65+
- In workflow YAML files, always use the latest major version of built-in
66+
GitHub Actions (e.g., `actions/checkout@v5`, `actions/upload-artifact@v4`).
67+
- In autosolve workflows, install the Claude CLI (npm) BEFORE any cloud
68+
authentication step, and move credential files out of the workspace
69+
immediately after authentication. npm post-install scripts run with the
70+
job's full environment, so installing after auth exposes credentials
71+
(e.g., the OIDC bearer token in `gha-creds-*.json`) to arbitrary code.
72+
- Do not silently swallow errors. In shell scripts, avoid `|| return 0`,
73+
`|| true`, or `|| :` to suppress failures without logging — use
74+
`log_warning` to surface what went wrong. In Go code, avoid `return nil`
75+
on error paths without logging or returning the error. If ignoring an
76+
error is genuinely correct (e.g., best-effort cleanup), add a comment
77+
explaining why it's safe to ignore.

actions_helpers.sh

100755100644
Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env bash
22
# Shared shell helpers for all GitHub Actions in this repo.
33

4-
# GitHub Actions log commands — structured annotations in CI, plain stderr locally.
4+
# GitHub Actions log commands — emit structured annotations via stdout.
55
log_error() { echo "::error::$*"; }
66
log_warning() { echo "::warning::$*"; }
77
log_notice() { echo "::notice::$*"; }
@@ -21,3 +21,14 @@ set_output_multiline() {
2121
echo "$delim"
2222
} >> "${GITHUB_OUTPUT:-/dev/null}"
2323
}
24+
25+
# Verify a command is on PATH: require_command <name>
26+
require_command() {
27+
command -v "$1" >/dev/null || { log_error "$1 not found on PATH"; return 1; }
28+
}
29+
30+
# Append content to the GitHub Actions step summary.
31+
# Usage: write_step_summary <<EOF ... EOF
32+
write_step_summary() {
33+
cat >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
34+
}

actions_helpers_test.sh

100755100644
Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env bash
22
# Tests for actions_helpers.sh helpers.
33
set -euo pipefail
4-
trap 'echo "Error occurred at line $LINENO"; exit 1' ERR
54

65
cd "$(dirname "${BASH_SOURCE[0]}")"
76
source ./test_helpers.sh
@@ -39,15 +38,15 @@ test_set_output() {
3938
GITHUB_OUTPUT="$TMPDIR_TEST/gh_out_single"
4039
touch "$GITHUB_OUTPUT"
4140
set_output "mykey" "myvalue"
42-
grep -q 'mykey=myvalue' "$GITHUB_OUTPUT"
41+
check_contains 'mykey=myvalue' "$GITHUB_OUTPUT"
4342
}
4443
expect_success "set_output: writes key=value" test_set_output
4544

4645
test_set_output_empty_value() {
4746
GITHUB_OUTPUT="$TMPDIR_TEST/gh_out_empty"
4847
touch "$GITHUB_OUTPUT"
4948
set_output "mykey" ""
50-
grep -q 'mykey=$' "$GITHUB_OUTPUT"
49+
check_contains 'mykey=' "$GITHUB_OUTPUT"
5150
}
5251
expect_success "set_output: handles empty value" test_set_output_empty_value
5352

@@ -58,7 +57,7 @@ test_set_output_multiline() {
5857
touch "$GITHUB_OUTPUT"
5958
set_output_multiline "desc" "line one
6059
line two"
61-
grep -q 'line one' "$GITHUB_OUTPUT" && grep -q 'line two' "$GITHUB_OUTPUT"
60+
check_contains 'line one' "$GITHUB_OUTPUT" && check_contains 'line two' "$GITHUB_OUTPUT"
6261
}
6362
expect_success "set_output_multiline: writes multiline content" test_set_output_multiline
6463

@@ -67,16 +66,24 @@ test_set_output_multiline_delimiters() {
6766
touch "$GITHUB_OUTPUT"
6867
set_output_multiline "desc" "content"
6968
# Should have opening delimiter (desc<<GHEOF_...) and closing delimiter (GHEOF_...)
70-
grep -q '^desc<<GHEOF_' "$GITHUB_OUTPUT" && grep -q '^GHEOF_' "$GITHUB_OUTPUT"
69+
check_contains_pattern '^desc<<GHEOF_' "$GITHUB_OUTPUT" && check_contains_pattern '^GHEOF_' "$GITHUB_OUTPUT"
7170
}
7271
expect_success "set_output_multiline: uses GHEOF delimiters" test_set_output_multiline_delimiters
7372

7473
test_set_output_multiline_empty() {
7574
GITHUB_OUTPUT="$TMPDIR_TEST/gh_out_multi_empty"
7675
touch "$GITHUB_OUTPUT"
7776
set_output_multiline "desc" ""
78-
grep -q '^desc<<GHEOF_' "$GITHUB_OUTPUT"
77+
check_contains_pattern '^desc<<GHEOF_' "$GITHUB_OUTPUT"
7978
}
8079
expect_success "set_output_multiline: handles empty value" test_set_output_multiline_empty
8180

81+
# --- require_command tests ---
82+
83+
test_require_command_found() { require_command bash; }
84+
expect_success "require_command: finds bash" test_require_command_found
85+
86+
test_require_command_missing() { require_command nonexistent_cmd_xyz; }
87+
expect_failure "require_command: fails for missing command" test_require_command_missing
88+
8289
print_results

autotag-from-changelog/auto-tag-release.sh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@
1111
# not tagged) and the script fails.
1212
set -euo pipefail
1313

14+
ORIG_DIR="$(pwd)"
15+
cd "$(dirname "${BASH_SOURCE[0]}")"
16+
source ../actions_helpers.sh
17+
cd "$ORIG_DIR"
18+
1419
changelog="${CHANGELOG_PATH:-CHANGELOG.md}"
1520

1621
# Check that the changelog file exists and is readable.
1722
if [ ! -r "$changelog" ]; then
18-
echo "::error::Changelog file '$changelog' does not exist or is not readable."
23+
log_error "Changelog file '$changelog' does not exist or is not readable."
1924
exit 1
2025
fi
2126
# Parse the changelog to extract the unreleased content and latest version.
@@ -68,7 +73,7 @@ if [ -n "$unreleased_content" ]; then
6873
echo "Content under [Unreleased] and ${tag} already tagged, nothing to do."
6974
exit 0
7075
else
71-
echo "::error::CHANGELOG.md has content under [Unreleased] but ${tag} is not tagged. Tag the previous release before adding new entries."
76+
log_error "CHANGELOG.md has content under [Unreleased] but ${tag} is not tagged. Tag the previous release before adding new entries."
7277
exit 1
7378
fi
7479
fi

autotag-from-changelog/auto-tag-release_test.sh

Lines changed: 18 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,22 @@
11
#!/usr/bin/env bash
22
# Tests for auto-tag-release.sh
33
set -euo pipefail
4-
trap 'echo "Error occurred at line $LINENO"; exit 1' ERR
54

6-
SCRIPT_DIR="$(cd "$(dirname "$0")"; pwd)"
7-
PASS=0
8-
FAIL=0
9-
10-
run_test() {
11-
local name="$1"
12-
local expected_exit="$2"
13-
local expected_output="$3"
14-
shift 3
15-
16-
local output exit_code
17-
output=$("$@" 2>&1) && exit_code=0 || exit_code=$?
18-
19-
if [ "$exit_code" -ne "$expected_exit" ]; then
20-
echo "FAIL: $name — expected exit $expected_exit, got $exit_code"
21-
echo " output: $output"
22-
FAIL=$((FAIL + 1))
23-
return
24-
fi
25-
26-
if [ -n "$expected_output" ] && ! echo "$output" | grep -qF "$expected_output"; then
27-
echo "FAIL: $name — expected output containing: $expected_output"
28-
echo " actual: $output"
29-
FAIL=$((FAIL + 1))
30-
return
31-
fi
32-
33-
echo "PASS: $name"
34-
PASS=$((PASS + 1))
35-
}
5+
cd "$(dirname "${BASH_SOURCE[0]}")"
6+
SCRIPT_DIR="$(pwd)"
7+
source ../test_helpers.sh
368

379
# Set up a temporary bare repo to act as "origin" and a working clone.
3810
TMPDIR=$(mktemp -d)
3911
trap 'rm -rf "$TMPDIR"' EXIT
4012

41-
git config user.email || git config --global user.email "test@test.com"
42-
git config user.name || git config --global user.name "test"
43-
git init --bare --initial-branch=main "$TMPDIR/origin.git"
44-
git clone "$TMPDIR/origin.git" "$TMPDIR/work"
13+
git config user.email >/dev/null || git config --global user.email "test@test.com"
14+
git config user.name >/dev/null || git config --global user.name "test"
15+
git init --bare --initial-branch=main "$TMPDIR/origin.git" >/dev/null
16+
git clone "$TMPDIR/origin.git" "$TMPDIR/work" >/dev/null
4517
cd "$TMPDIR/work"
46-
git commit --allow-empty -m "initial"
47-
git push origin main
18+
git commit --allow-empty -m "initial" >/dev/null
19+
git push origin main >/dev/null
4820

4921
# =============================================
5022
# parse_changelog unit tests
@@ -167,7 +139,7 @@ cat <<'EOF' > CHANGELOG.md
167139
## [1.0.0] - 2026-01-01
168140
EOF
169141

170-
run_test "content under [Unreleased], untagged version" 1 "is not tagged" \
142+
expect_failure_output "content under [Unreleased], untagged version" "is not tagged" \
171143
env CHANGELOG_PATH=CHANGELOG.md "$SCRIPT_DIR/auto-tag-release.sh"
172144

173145
# --- Empty [Unreleased], new version, no existing tag — should tag ---
@@ -177,11 +149,11 @@ cat <<'EOF' > CHANGELOG.md
177149
## [1.0.0] - 2026-01-01
178150
EOF
179151

180-
run_test "creates new tag" 0 "Tagged v1.0.0 successfully" \
152+
expect_success_output "creates new tag" "Tagged v1.0.0 successfully" \
181153
env CHANGELOG_PATH=CHANGELOG.md "$SCRIPT_DIR/auto-tag-release.sh"
182154

183155
# Verify the tag was actually created
184-
if git rev-parse v1.0.0; then
156+
if git rev-parse v1.0.0 >/dev/null; then
185157
echo "PASS: tag v1.0.0 exists"
186158
PASS=$((PASS + 1))
187159
else
@@ -190,7 +162,7 @@ else
190162
fi
191163

192164
# --- Tag already exists — should skip ---
193-
run_test "tag already exists" 0 "already exists" \
165+
expect_success_output "tag already exists" "already exists" \
194166
env CHANGELOG_PATH=CHANGELOG.md "$SCRIPT_DIR/auto-tag-release.sh"
195167

196168
# --- Content under [Unreleased], previous version already tagged — should pass ---
@@ -204,23 +176,23 @@ cat <<'EOF' > CHANGELOG.md
204176
## [1.0.0] - 2026-01-01
205177
EOF
206178

207-
run_test "content under [Unreleased], tagged version" 0 "already tagged" \
179+
expect_success_output "content under [Unreleased], tagged version" "already tagged" \
208180
env CHANGELOG_PATH=CHANGELOG.md "$SCRIPT_DIR/auto-tag-release.sh"
209181

210182
# --- No version sections at all — should skip ---
211183
cat <<'EOF' > CHANGELOG.md
212184
## [Unreleased]
213185
EOF
214186

215-
run_test "no released version" 0 "No released version" \
187+
expect_success_output "no released version" "No released version found" \
216188
env CHANGELOG_PATH=CHANGELOG.md "$SCRIPT_DIR/auto-tag-release.sh"
217189

218190
# --- No [Unreleased] section — should skip ---
219191
cat <<'EOF' > CHANGELOG.md
220192
## [2.0.0] - 2026-01-01
221193
EOF
222194

223-
run_test "no [Unreleased] section" 0 "No released version" \
195+
expect_success "no [Unreleased] section" \
224196
env CHANGELOG_PATH=CHANGELOG.md "$SCRIPT_DIR/auto-tag-release.sh"
225197

226198
# --- [Unreleased] with only blank lines (no content) — should tag ---
@@ -232,10 +204,7 @@ cat <<'EOF' > CHANGELOG.md
232204
## [2.0.0] - 2026-01-01
233205
EOF
234206

235-
run_test "blank lines under [Unreleased]" 0 "Tagged v2.0.0 successfully" \
207+
expect_success "blank lines under [Unreleased]" \
236208
env CHANGELOG_PATH=CHANGELOG.md "$SCRIPT_DIR/auto-tag-release.sh"
237209

238-
# --- Summary ---
239-
echo ""
240-
echo "Results: $PASS passed, $FAIL failed"
241-
[ "$FAIL" -eq 0 ] || exit 1
210+
print_results

test.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
# Run all tests in the repository.
33
set -euo pipefail
44

5-
SCRIPT_DIR="$(cd "$(dirname "$0")"; pwd)"
5+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd)"
66
FAILED=0
77

88
test_files=()
99
while IFS= read -r f; do
1010
test_files+=("$f")
11-
done < <(find "$SCRIPT_DIR" -mindepth 2 -name '*_test.sh' | sort)
11+
done < <(find "$SCRIPT_DIR" -mindepth 1 -name '*_test.sh' | sort)
1212

1313
if [ "${#test_files[@]}" -eq 0 ]; then
1414
echo "No tests found."

test_helpers.sh

100755100644
Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,39 @@
44
# Usage:
55
# source test_helpers.sh
66
# expect_success "test name" command [args...]
7-
# expect_failure "test name" "expected output substring" command [args...]
7+
# expect_success_output "test name" "expected output" command [args...]
88
# expect_failure "test name" command [args...]
9+
# expect_failure_output "test name" "expected output" command [args...]
910
# print_results
1011

1112
PASS=0
1213
FAIL=0
1314

15+
# Enable ERR trap for all test files that source this helper.
16+
trap 'echo "Error occurred at line $LINENO"; exit 1' ERR
17+
18+
# Assert that input contains a given substring.
19+
# Usage: check_contains "expected" "$file" (reads file)
20+
# cmd | check_contains "expected" (reads stdin)
21+
check_contains() {
22+
if [ $# -ge 2 ]; then
23+
grep --quiet --fixed-strings "$1" "$2"
24+
else
25+
grep --quiet --fixed-strings "$1"
26+
fi
27+
}
28+
29+
# Assert that input matches a given regex pattern.
30+
# Usage: check_contains_pattern "^prefix" "$file" (reads file)
31+
# cmd | check_contains_pattern "^prefix" (reads stdin)
32+
check_contains_pattern() {
33+
if [ $# -ge 2 ]; then
34+
grep --quiet "$1" "$2"
35+
else
36+
grep --quiet "$1"
37+
fi
38+
}
39+
1440
_run_test() {
1541
local name="$1"
1642
local expected_exit="$2" # exact code, or "nonzero" for any non-zero
@@ -34,7 +60,7 @@ _run_test() {
3460
return
3561
fi
3662

37-
if [ -n "$expected_output" ] && ! echo "$output" | grep -qF "$expected_output"; then
63+
if [ -n "$expected_output" ] && ! echo "$output" | grep --quiet --fixed-strings "$expected_output"; then
3864
echo "FAIL: $name — expected output containing: $expected_output"
3965
echo " actual: $output"
4066
FAIL=$((FAIL + 1))
@@ -45,22 +71,34 @@ _run_test() {
4571
PASS=$((PASS + 1))
4672
}
4773

74+
# expect_success "test name" command [args...]
4875
# expect_success "test name" command [args...]
4976
# Asserts the command exits 0.
5077
expect_success() {
5178
local name="$1"; shift
5279
_run_test "$name" 0 "" "$@"
5380
}
5481

55-
# expect_failure "test name" ["expected output substring"] command [args...]
56-
# Asserts the command exits non-zero. If the second arg is not a valid command,
57-
# it is treated as an expected output substring.
82+
# expect_success_output "test name" "expected output substring" command [args...]
83+
# Asserts the command exits 0 and output contains the expected substring.
84+
expect_success_output() {
85+
local name="$1"; shift
86+
local expected_output="$1"; shift
87+
_run_test "$name" 0 "$expected_output" "$@"
88+
}
89+
90+
# expect_failure "test name" command [args...]
91+
# Asserts the command exits non-zero.
5892
expect_failure() {
5993
local name="$1"; shift
60-
local expected_output=""
61-
if ! command -v "$1" >/dev/null 2>&1 && ! declare -F "$1" >/dev/null 2>&1; then
62-
expected_output="$1"; shift
63-
fi
94+
_run_test "$name" "nonzero" "" "$@"
95+
}
96+
97+
# expect_failure_output "test name" "expected output substring" command [args...]
98+
# Asserts the command exits non-zero and output contains the expected substring.
99+
expect_failure_output() {
100+
local name="$1"; shift
101+
local expected_output="$1"; shift
64102
_run_test "$name" "nonzero" "$expected_output" "$@"
65103
}
66104

0 commit comments

Comments
 (0)