Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 66 additions & 7 deletions .github/workflows/shell-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-depth: 1
persist-credentials: false

- name: Install pinned ShellCheck
Expand Down Expand Up @@ -58,7 +58,34 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [motd, push, other]
include:
# motd and cloudflare are cut into eight segments; each of their two
# jobs runs four of them, so a single suite spans two jobs.
- shard: motd-a
suite: motd
segments: 8
segment_set: "0 1 2 3"
- shard: motd-b
suite: motd
segments: 8
segment_set: "4 5 6 7"
- shard: push-worker
suite: push-worker
segments: 4
- shard: push
suite: push
segments: 1
- shard: cloudflare-a
suite: cloudflare
segments: 8
segment_set: "0 1 2 3"
- shard: cloudflare-b
suite: cloudflare
segments: 8
segment_set: "4 5 6 7"
- shard: other
suite: other
segments: 1
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -84,8 +111,10 @@ jobs:
env:
TEST_BASE_SHA: ${{ github.event.pull_request.base.sha }}
TEST_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
TEST_SHARD: ${{ matrix.shard }}
run: bash tests/run.sh "$TEST_SHARD"
TEST_SUITE: ${{ matrix.suite }}
TEST_SEGMENTS: ${{ matrix.segments }}
TEST_SEGMENT_SET: ${{ matrix.segment_set }}
run: bash tests/run.sh "$TEST_SUITE"

debian-tests:
name: Debian 13 / ${{ matrix.shard }}
Expand All @@ -95,7 +124,34 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [motd, push, other]
include:
# motd and cloudflare are cut into eight segments; each of their two
# jobs runs four of them, so a single suite spans two jobs.
- shard: motd-a
suite: motd
segments: 8
segment_set: "0 1 2 3"
- shard: motd-b
suite: motd
segments: 8
segment_set: "4 5 6 7"
- shard: push-worker
suite: push-worker
segments: 4
- shard: push
suite: push
segments: 1
- shard: cloudflare-a
suite: cloudflare
segments: 8
segment_set: "0 1 2 3"
- shard: cloudflare-b
suite: cloudflare
segments: 8
segment_set: "4 5 6 7"
- shard: other
suite: other
segments: 1
steps:
- name: Install test dependencies
run: |
Expand All @@ -118,14 +174,17 @@ jobs:
bash tests/test-selection-git.sh

- name: Check Bash syntax
if: matrix.shard == 'other'
run: find . -type f -name '*.sh' -print0 | xargs -0 -n1 bash -n

- name: Run tests
env:
TEST_BASE_SHA: ${{ github.event.pull_request.base.sha }}
TEST_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
TEST_SHARD: ${{ matrix.shard }}
run: bash tests/run.sh "$TEST_SHARD"
TEST_SUITE: ${{ matrix.suite }}
TEST_SEGMENTS: ${{ matrix.segments }}
TEST_SEGMENT_SET: ${{ matrix.segment_set }}
run: bash tests/run.sh "$TEST_SUITE"

# Preserve existing required-check names; skipped/cancelled dependencies fail closed.
ubuntu:
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@
生产宿主机(包括 Netcup)仅执行差异检查及改动脚本的 `bash -n`,不运行全量 ShellCheck 或完整测试集;
分析和测试交由 CI:PR 按完整差异及依赖映射选择套件,README/许可证修改无需业务测试;
公共设施或未知依赖改动回退全量,main 推送仍执行全量。两个必需 Job 始终运行,输出选择和跳过记录。
Ubuntu 使用固定 ShellCheck v0.10.0:所有受 Git 跟踪、位于 tests/ 之外的 .sh 文件执行 warning 门槛,tests/ 内的 .sh 文件执行 error 门槛;新目录中的生产 .sh 文件自动纳入,不维护生产文件白名单。Zsh 配置 p10k-config.zsh 不交给 Bash ShellCheck。两组 lint 都会执行并累计失败,Debian 不重复 lint。
每个 OS 的测试作业按七个 shard 并行:`motd-a`/`motd-b`(`test-motd.sh`,各跑四段)、
`cloudflare-a`/`cloudflare-b`(`test-cloudflare-tunnel.sh`,各跑四段)、`push-worker`
(`test-push-worker-registration.sh`)、`push`(仅 `test-push.sh`)、`other`(其余全部套件);
本机全量用 `tests/run.sh all`。
重套件(motd、Cloudflare Tunnel、push worker 注册)按语义分段,import 守卫 `seg_is`:`TEST_SEGMENTS` 个进程各跑互不重叠的一段并同时执行(本机默认 1,即按原顺序串行)。
motd 与 Cloudflare Tunnel 各切成八段:`-a` 作业用 `TEST_SEGMENT_SET="0 1 2 3"`、`-b` 作业用 `"4 5 6 7"`,两个作业的并集恰好覆盖全部段且互不重叠;push worker 注册仍为四段、单作业。
`TEST_SEGMENT_SET` 只接受互不重复、且在 `TEST_SEGMENTS` 范围内的段下标,非法值时在任何套件启动前以状态 2 退出;未设置则照旧跑全部段。
Ubuntu 使用固定 ShellCheck v0.10.0:所有受 Git 跟踪、位于 tests/ 之外的 .sh 文件执行 warning 门槛,tests/ 与 tests/lib/ 内的 .sh 文件执行 error 门槛;新目录中的生产 .sh 文件自动纳入,不维护生产文件白名单。Zsh 配置 p10k-config.zsh 不交给 Bash ShellCheck。两组 lint 都会执行并累计失败,Debian 不重复 lint。
例外仅允许局部明确的 SC 规则编号和邻近理由;禁止文件级或全局 warning 抑制。测试 warning 不阻断不等于被 disable,禁止用抑制隐藏行为缺陷。CI 工作流改动继续触发全量测试回退;warning 门槛失败时后续 Ubuntu 行为套件不会执行,不能以 Debian 通过代替双环境验收。
语法检查仍覆盖全部 .sh 脚本;文档修改无需本地全套测试,但仍须通过必需 CI。
本机重测试必须使用原生资源限制及权限隔离;无法隔离或超限时停止,不自动提高额度或转宿主机执行。
Expand Down
139 changes: 128 additions & 11 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ readonly ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
# Positional, not exported: nested selection fixtures must still run all suites.
shard=${1:-all}
if (( $# > 1 )); then
printf 'Usage: tests/run.sh [all|motd|push|other]\n' >&2
printf 'Usage: tests/run.sh [all|motd|push-worker|push|cloudflare|other]\n' >&2
exit 2
fi
case "$shard" in
all|motd|push|other) ;;
all|motd|push-worker|push|cloudflare|other) ;;
*) printf 'Invalid test shard: %s\n' "$shard" >&2; exit 2 ;;
esac

Expand All @@ -29,6 +29,7 @@ if [[ -n "${TEST_BASE_SHA:-}" && -n "${TEST_HEAD_SHA:-}" ]]; then
tools/push.sh) selected[test-push.sh]=1; selected[test-push-worker-registration.sh]=1 ;;
tools/cloudflare_tunnel.sh) selected[test-cloudflare-tunnel.sh]=1 ;;
modules/ssh-security.sh) selected[test-ssh-security.sh]=1 ;;
modules/system-optimize.sh) selected[test-system-optimize.sh]=1 ;;
linux_setup.sh) selected[test-linux-setup.sh]=1; selected[test-setup-exec.sh]=1 ;;
modules/zsh-setup.sh|p10k-config.zsh) selected[test-linux-setup.sh]=1 ;;
tests/test-*.sh)
Expand All @@ -43,35 +44,151 @@ if [[ -n "${TEST_BASE_SHA:-}" && -n "${TEST_HEAD_SHA:-}" ]]; then
fi
printf 'Test selection: full=%s\n' "$full"

plan=()
for test_file in "$ROOT_DIR"/tests/test-*.sh; do
if [[ "$full" != true && -z "${selected[${test_file##*/}]:-}" ]]; then
printf 'SKIP: %s (unaffected by PR diff)\n' "${test_file##*/}"
test_name=$(basename "$test_file")
if [[ "$full" != true && -z "${selected[${test_name}]:-}" ]]; then
printf 'SKIP: %s (unaffected by PR diff)\n' "$test_name"
continue
fi
test_name=$(basename "$test_file")
# One suite per heavy family so CI can schedule them independently; every
# other suite shares `other`.
case "$test_name" in
test-motd.sh) suite_shard=motd ;;
test-push.sh|test-push-worker-registration.sh) suite_shard=push ;;
test-push-worker-registration.sh) suite_shard=push-worker ;;
test-push.sh) suite_shard=push ;;
test-cloudflare-tunnel.sh) suite_shard=cloudflare ;;
*) suite_shard=other ;;
esac
if [[ "$shard" != all && "$shard" != "$suite_shard" ]]; then
printf 'SKIP: %s (assigned to %s shard)\n' "$test_name" "$suite_shard"
continue
fi
printf '\n==> %s\n' "$test_name"
plan+=("$test_file")
done

# Suites carrying CI segment guards, with the number of segments their source
# defines. TEST_SEGMENTS sets how many processes share the work; with
# TEST_SEGMENTS=1 every suite runs in one process, all segments in order.
declare -rA SEGMENTED_SUITES=(
[test-motd.sh]=8
[test-cloudflare-tunnel.sh]=8
[test-push-worker-registration.sh]=4
)
segments=${TEST_SEGMENTS:-1}
# One digit, at most the eight segments a suite actually has: no accepted value
# can overflow shell arithmetic and every index below stays a single character.
[[ "$segments" =~ ^[1-8]$ ]] || { printf 'Invalid TEST_SEGMENTS: %s\n' "$segments" >&2; exit 2; }

# TEST_SEGMENT_SET narrows one job to a subset of the segments ("0 1 2 3"), so a
# single suite can be spread over several jobs. Unset or blank keeps every
# segment; anything that is not a distinct in-range index aborts before a suite
# starts. `read -a` splits on IFS without pathname expansion, so an entry such
# as "[1]" is compared literally instead of being matched against file names.
segment_set=()
seen_segments=" "
if [[ -n "${TEST_SEGMENT_SET:-}" && -n "${TEST_SEGMENT_SET//[[:space:]]/}" ]]; then
# Flatten embedded newlines first: `read -a` stops at the first line, which
# would drop the rest of the set without a word.
segment_value=${TEST_SEGMENT_SET//$'\t'/ }
segment_value=${segment_value//$'\n'/ }
read -r -a segment_entries <<< "$segment_value"
for segment_index in "${segment_entries[@]}"; do
[[ "$segment_index" =~ ^[0-7]$ ]] \
|| { printf 'Invalid TEST_SEGMENT_SET entry: %s\n' "$segment_index" >&2; exit 2; }
(( segment_index < segments )) \
|| { printf 'Segment %s out of range for TEST_SEGMENTS=%s\n' "$segment_index" "$segments" >&2; exit 2; }
[[ "$seen_segments" != *" $segment_index "* ]] \
|| { printf 'Duplicate segment in TEST_SEGMENT_SET: %s\n' "$segment_index" >&2; exit 2; }
seen_segments+="$segment_index "
segment_set+=("$segment_index")
done
(( segments > 1 )) \
|| { printf 'TEST_SEGMENT_SET requires TEST_SEGMENTS above 1\n' >&2; exit 2; }
fi

# The whole plan must agree with the segment counts above before the first suite
# starts: a count that matches no suite (or a subset aimed at an unsegmented one)
# would otherwise silently run part of the work and still exit 0.
for test_file in "${plan[@]:-}"; do
[[ -n "$test_file" ]] || continue
test_name=$(basename "$test_file")
declared_segments=${SEGMENTED_SUITES[${test_name}]:-1}
if (( ${#segment_set[@]} )) && [[ -z "${SEGMENTED_SUITES[${test_name}]:-}" ]]; then
printf 'TEST_SEGMENT_SET does not apply to unsegmented suite: %s\n' "$test_name" >&2
exit 2
fi
(( segments == 1 || segments == declared_segments )) \
|| { printf 'TEST_SEGMENTS=%s but %s has %s segments\n' "$segments" "$test_name" "$declared_segments" >&2; exit 2; }
done

is_segmented() {
local name=$1
(( segments > 1 )) || return 1
[[ -n "${SEGMENTED_SUITES[${name}]:-}" ]]
}

run_suite() {
local test_file=$1 seg=${2:-} test_name started status
test_name=$(basename "$test_file")
if [[ -n "$seg" ]]; then
test_name+=" [segment $((seg + 1))/$segments]"
fi
started=$SECONDS
printf '\n==> %s\n' "$test_name"
# Keep the child shell standalone: an if/|| wrapper can change errexit behavior.
# Nested fixtures re-enter this script, so neither setting may leak to them.
set +e
bash "$test_file"
if [[ -n "$seg" ]]; then
env -u TEST_JOBS -u TEST_SEGMENTS -u TEST_SEGMENT_SET TEST_SEGMENT="$seg" bash "$test_file"
else
env -u TEST_JOBS -u TEST_SEGMENTS -u TEST_SEGMENT -u TEST_SEGMENT_SET bash "$test_file"
fi
status=$?
set -e
elapsed=$((SECONDS - started))
local elapsed=$((SECONDS - started))
printf 'TIMING: %s elapsed=%ss exit=%s\n' "$test_name" "$elapsed" "$status"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
printf -- '- `%s`: %ss, exit %s\n' "$test_name" "$elapsed" "$status" >> "$GITHUB_STEP_SUMMARY"
fi
if (( status != 0 )); then
exit "$status"
return "$status"
}

# Segments of one suite share no state, so they run together and fill the
# runner; the suites of a shard still run one after another.
run_segments() {
local test_file=$1 dir k rc failed=0 pids=()
local -a queue=()
dir=$(mktemp -d)
if (( ${#segment_set[@]} )); then
queue=("${segment_set[@]}")
else
for (( k = 0; k < segments; k++ )); do queue+=("$k"); done
fi
for k in "${queue[@]}"; do
(
rc=0
run_suite "$test_file" "$k" > "$dir/$k.log" 2>&1 || rc=$?
printf '%s' "$rc" > "$dir/$k.rc"
) &
pids+=("$!")
done
for k in "${pids[@]}"; do wait "$k" || true; done
for k in "${queue[@]}"; do
cat "$dir/$k.log"
rc=$(cat "$dir/$k.rc" 2>/dev/null || printf 1)
(( rc == 0 )) || failed=1
done
rm -rf "$dir"
return "$failed"
}

for test_file in "${plan[@]:-}"; do
[[ -n "$test_file" ]] || continue
if is_segmented "$(basename "$test_file")"; then
run_segments "$test_file" || exit 1
else
run_suite "$test_file" || exit "$?"
fi
done

Expand Down
Loading