Skip to content

Commit 86b7e52

Browse files
committed
test(smoke): add bash 5.1 smoke fixture for set -u empty-array regression
intent: catch regressions of the recently-fixed `set -u` + empty-array unbound-variable bug in run_after_sync-skills.sh.tmpl. shellcheck cannot detect this class of failure (it surfaces only at runtime), so the safety net has to be a fixture-driven smoke test that actually executes the rendered template under the conditions that triggered the original report. decision: pin the workflow to ubuntu-22.04 (bash 5.1.16). ubuntu-latest moved to 24.04 (bash 5.2.21), and bash 5.2 silently tolerates empty-array expansion under set -u — so testing on the default runner would let the regression slide. ubuntu-22.04 has GitHub support through ~2027; migrate to a bash:5.1 Docker container in a follow-up PR when EOL approaches. decision: runner allocates an isolated HOME via mktemp -d and runs the fixture's setup.sh + the rendered template under that HOME. This blocks the target script's rsync calls from touching the real ~/.claude/skills, without needing any additional assertion machinery. decision: ship two scenarios — empty-env reproduces the original bug exactly, apm-only verifies the PRIVATE_EXCLUDE_ARGS guard is independently effective (removing only that guard still passes empty-env). private-only is deferred until a regression demands it. decision: record reproduction metadata (repro_before commit, fixed_in commit, reproduces_on bash version) inline in each setup.sh header comment instead of a JSON sidecar. git blame is the lookup mechanism. learned: the bug masks on bash 5.2+, so local Red verification is only possible by running the smoke under a bash < 5.2 binary. CI on ubuntu-22.04 is the contract — local pnpm smoke:chezmoi confirms the Green state only.
1 parent fb689dd commit 86b7e52

5 files changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Smoke (chezmoi scripts)
2+
3+
on:
4+
push:
5+
branches: [master, main]
6+
paths:
7+
- "home/.chezmoiscripts/**"
8+
- "tests/smoke/**"
9+
- "scripts/smoke-chezmoi-scripts.sh"
10+
- ".github/workflows/ci-smoke-chezmoi.yml"
11+
pull_request:
12+
branches: [master, main]
13+
paths:
14+
- "home/.chezmoiscripts/**"
15+
- "tests/smoke/**"
16+
- "scripts/smoke-chezmoi-scripts.sh"
17+
- ".github/workflows/ci-smoke-chezmoi.yml"
18+
workflow_dispatch:
19+
20+
concurrency:
21+
group: ${{ github.workflow }}-${{ github.ref }}
22+
cancel-in-progress: true
23+
24+
permissions:
25+
contents: read
26+
27+
jobs:
28+
smoke:
29+
name: Smoke (bash 5.1)
30+
# Pinned to ubuntu-22.04 (bash 5.1.16) on purpose: the original empty-array
31+
# bug is masked on bash 5.2+. ubuntu-latest moved to ubuntu-24.04 (bash
32+
# 5.2.21), where the smoke would silently pass against unfixed code. When
33+
# ubuntu-22.04 reaches end of support (~2027), migrate this job to a
34+
# bash:5.1 Docker container in a separate PR.
35+
runs-on: ubuntu-22.04
36+
37+
steps:
38+
- name: Checkout repository
39+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
40+
with:
41+
persist-credentials: false
42+
43+
- name: Report bash version (sanity check)
44+
run: bash --version | head -1
45+
46+
- name: Install chezmoi
47+
uses: ./.github/actions/install-chezmoi
48+
with:
49+
install-dir: "$HOME/.local/bin"
50+
add-to-path: "true"
51+
52+
- name: Run smoke fixtures
53+
run: ./scripts/smoke-chezmoi-scripts.sh

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"ci:local:shell": "./scripts/ci-local.sh shell",
1818
"ci:local:typescript": "./scripts/ci-local.sh typescript",
1919
"lint:shell": "./scripts/lint-shell.sh",
20+
"smoke:chezmoi": "./scripts/smoke-chezmoi-scripts.sh",
2021
"lint:actions": "./scripts/lint-actions.sh",
2122
"lint:codex": "./scripts/check-codex-config.sh",
2223
"lint:oxlint": "oxlint --ignore-pattern '.claude/skills/' --ignore-pattern '.codex/skills/' --ignore-pattern '.skills/' --ignore-pattern 'node_modules/' .",

scripts/smoke-chezmoi-scripts.sh

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env bash
2+
# shellcheck shell=bash
3+
# Smoke test runner for chezmoi script templates.
4+
#
5+
# Layout convention:
6+
# tests/smoke/<script-name>/<scenario>/setup.sh
7+
#
8+
# <script-name> matches the basename of a template under
9+
# home/.chezmoiscripts/<script-name>.sh.tmpl. For each scenario:
10+
# 1. Allocate an isolated HOME directory (mktemp -d).
11+
# 2. Run setup.sh with HOME pointed at the tempdir to construct the
12+
# scenario state (e.g. write a dummy ~/.apm/apm.lock.yaml).
13+
# 3. chezmoi execute-template the target script.
14+
# 4. Execute the rendered script under the same isolated HOME.
15+
# 5. Pass if exit code is 0; fail with a stderr dump of the rendered
16+
# script otherwise.
17+
18+
set -euo pipefail
19+
20+
RED='\033[0;31m'
21+
GREEN='\033[0;32m'
22+
YELLOW='\033[1;33m'
23+
NC='\033[0m'
24+
25+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26+
CHEZMOI_SOURCE_PATH="${REPO_ROOT}/home"
27+
SMOKE_ROOT="${REPO_ROOT}/tests/smoke"
28+
29+
if [[ ! -d "$SMOKE_ROOT" ]]; then
30+
echo -e "${YELLOW}No smoke fixtures found at ${SMOKE_ROOT}${NC}"
31+
exit 0
32+
fi
33+
34+
if ! command -v chezmoi >/dev/null 2>&1; then
35+
echo -e "${RED}chezmoi is required but not found${NC}"
36+
exit 1
37+
fi
38+
39+
# Single tmp parent dir cleaned up on exit, regardless of which iteration
40+
# the script dies in.
41+
SMOKE_TMP_ROOT=$(mktemp -d -t chezmoi-smoke-XXXXXX)
42+
trap 'rm -rf "${SMOKE_TMP_ROOT}"' EXIT
43+
44+
PASS_COUNT=0
45+
FAIL_COUNT=0
46+
47+
shopt -s nullglob
48+
49+
for script_dir in "${SMOKE_ROOT}"/*/; do
50+
script_name="$(basename "$script_dir")"
51+
template_path="${CHEZMOI_SOURCE_PATH}/.chezmoiscripts/${script_name}.sh.tmpl"
52+
53+
if [[ ! -f "$template_path" ]]; then
54+
echo -e "${RED}Target template not found for fixture group '${script_name}': ${template_path}${NC}"
55+
FAIL_COUNT=$((FAIL_COUNT + 1))
56+
continue
57+
fi
58+
59+
for scenario_dir in "${script_dir}"*/; do
60+
scenario_name="$(basename "$scenario_dir")"
61+
setup_script="${scenario_dir}setup.sh"
62+
63+
if [[ ! -f "$setup_script" ]]; then
64+
echo -e "${YELLOW}No setup.sh in ${scenario_dir}, skipping${NC}"
65+
continue
66+
fi
67+
68+
echo "═══════════════════════════════════════════════"
69+
echo "Smoke: ${script_name} / ${scenario_name}"
70+
echo "═══════════════════════════════════════════════"
71+
72+
smoke_home="${SMOKE_TMP_ROOT}/${script_name}__${scenario_name}"
73+
mkdir -p "$smoke_home"
74+
rendered="${smoke_home}/__rendered.sh"
75+
76+
if ! HOME="$smoke_home" bash "$setup_script"; then
77+
echo -e "${RED}✗ setup.sh failed${NC}"
78+
FAIL_COUNT=$((FAIL_COUNT + 1))
79+
continue
80+
fi
81+
82+
if ! chezmoi execute-template --source "$CHEZMOI_SOURCE_PATH" < "$template_path" > "$rendered"; then
83+
echo -e "${RED}✗ template render failed${NC}"
84+
FAIL_COUNT=$((FAIL_COUNT + 1))
85+
continue
86+
fi
87+
88+
if HOME="$smoke_home" bash "$rendered"; then
89+
echo -e "${GREEN}✓ PASS${NC}"
90+
PASS_COUNT=$((PASS_COUNT + 1))
91+
else
92+
exit_code=$?
93+
echo -e "${RED}✗ FAIL (rendered script exited with ${exit_code})${NC}"
94+
{
95+
echo "--- rendered script (${template_path}) ---"
96+
cat "$rendered"
97+
echo "--- end rendered ---"
98+
} >&2
99+
FAIL_COUNT=$((FAIL_COUNT + 1))
100+
fi
101+
done
102+
done
103+
104+
echo ""
105+
echo "Smoke summary: ${PASS_COUNT} passed, ${FAIL_COUNT} failed"
106+
107+
if [[ $FAIL_COUNT -gt 0 ]]; then
108+
exit 1
109+
fi
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/usr/bin/env bash
2+
# Smoke fixture: apm.lock.yaml present, private-skills absent.
3+
#
4+
# State setup:
5+
# $HOME/.apm/apm.lock.yaml : present (one synthetic skill entry)
6+
# $HOME/.local/share/private-skills/ : ABSENT
7+
#
8+
# With apm present and private-skills absent, EXCLUDE_ARGS gets one element
9+
# while PRIVATE_EXCLUDE_ARGS stays empty. This independently verifies the
10+
# PRIVATE_EXCLUDE_ARGS guard: removing only the PRIVATE_EXCLUDE_ARGS guard
11+
# from the rsync line would still pass empty-env (both empty masks the
12+
# distinction) but would fail this fixture under bash < 5.2.
13+
14+
set -euo pipefail
15+
16+
mkdir -p "$HOME/.apm"
17+
cat > "$HOME/.apm/apm.lock.yaml" <<'EOF'
18+
# Synthetic apm.lock.yaml for smoke testing.
19+
# The target script greps for lines matching '^ - \.claude/skills/'.
20+
artifacts:
21+
- .claude/skills/synthetic-skill-for-smoke
22+
EOF
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
# Smoke fixture: empty environment — the original bug's repro condition.
3+
#
4+
# State setup:
5+
# $HOME/.apm/apm.lock.yaml : ABSENT
6+
# $HOME/.local/share/private-skills/ : ABSENT
7+
#
8+
# With both absent, the target script's EXCLUDE_ARGS and PRIVATE_EXCLUDE_ARGS
9+
# arrays remain empty; the rsync line then expands "${arr[@]}" under set -u,
10+
# which errors on bash < 5.2 (the originally-reported failure).
11+
#
12+
# repro_before: 986c2e8^ (parent of the fix commit)
13+
# fixed_in: 986c2e8 (rsync expansion guarded with ${arr[@]+"${arr[@]}"})
14+
# reproduces_on: bash < 5.2 (5.2+ silently tolerates empty-array expansion,
15+
# so this fixture only catches regressions on CI
16+
# runners pinned to ubuntu-22.04 / bash 5.1.16).
17+
18+
set -euo pipefail
19+
20+
# Absence of state IS the fixture; the runner already provides an isolated HOME.
21+
:

0 commit comments

Comments
 (0)