diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 686f98d7..03aff3da 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -36,6 +36,7 @@ Contributions are licensed under the [MIT License](https://github.com/TypedDevs/ - Make - [ShellCheck](https://github.com/koalaman/shellcheck#installing) - [editorconfig-checker](https://github.com/editorconfig-checker/editorconfig-checker#installation) +- [shfmt](https://github.com/mvdan/sh) and [jq](https://jqlang.org/) when building the standalone binary ### Setup diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 28b290a8..e6c6f93c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,18 @@ jobs: with: fetch-depth: 1 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: '1.25' + cache: false + + - name: Install standalone build optimizer + shell: bash + run: | + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + go install mvdan.cc/sh/v3/cmd/shfmt@v3.13.1 + - name: Build and verify shell: bash run: | diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5a3a5c29..94772c54 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -33,6 +33,18 @@ jobs: npm install -g npm@latest echo "npm version after upgrade: $(npm -v)" + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: '1.25' + cache: false + + - name: Install standalone build optimizer + shell: bash + run: | + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + go install mvdan.cc/sh/v3/cmd/shfmt@v3.13.1 + - name: Build bashunit single-file binary shell: bash run: ./build.sh bin diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea001ef..7154ed16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Performance: `assert_within_delta` uses fixed-point arithmetic for common values, with a `bc`/`awk` fallback for unsupported inputs (about 6.6x faster) (#979) - Performance: Spy assertions and call counters use builtins instead of `cat` and command substitutions (about 6.5x faster) (#978) - Performance: `assert_contains_ignore_case` uses Bash's `nocasematch` where available, falling back to `tr` on Bash 3.0 (about 10x faster) (#977) +- Build: standalone binaries omit source comments while preserving heredoc content and source markers, reducing the current artifact by about 22% (#990) - Internal: Split `src/runner.sh` and `src/coverage.sh` into focused modules with no behavior change; see [ADR-010](adrs/adr-010-src-module-directories.md) (#924, #925) ### Fixed diff --git a/adrs/adr-011-source-layout-and-build-pipeline.md b/adrs/adr-011-source-layout-and-build-pipeline.md index 6cbdb6ae..faf3f130 100644 --- a/adrs/adr-011-source-layout-and-build-pipeline.md +++ b/adrs/adr-011-source-layout-and-build-pipeline.md @@ -110,8 +110,10 @@ invisible to it. 3. strip every `^source ` line from the result 4. build::embed_docs swap docs/assertions.md into a heredoc between the markers in src/cli/doc.sh, so the binary needs no docs/ directory -5. build::assert_valid_syntax bash -n -6. build::verify (-v) run the whole suite against the built binary +5. build::strip_comments parse the assembled Bash and remove source comments, retaining + the shebang, heredoc content and `# src/...` boundary markers +6. build::assert_valid_syntax bash -n +7. build::verify (-v) run the whole suite against the built binary ``` **Step 2 is why an aggregator may hold only `source` lines.** A file's body is emitted *before* diff --git a/build.sh b/build.sh index 0949a678..c93ce563 100755 --- a/build.sh +++ b/build.sh @@ -62,6 +62,15 @@ function build::generate_bin() { # Embed the assertions.md docs into the binary build::embed_docs "$out" + # Keep the source tree documented without shipping those comments in every + # standalone binary. A shell parser is required here: the built file contains + # heredocs whose Markdown and example scripts legitimately start with `#`. + # Structural build tests use the raw form so platforms without release-tool + # dependencies still exercise bundling; release builds always take this path. + if [[ ${_BASHUNIT_BUILD_SKIP_COMMENT_STRIP:-false} != true ]]; then + build::strip_comments "$out" + fi + build::assert_valid_syntax "$out" } @@ -156,6 +165,42 @@ function build::embed_docs() { chmod u+x "$file" } +function build::strip_comments() { + local file=$1 + local temp_file="${file}.tmp" + + local dependency + for dependency in shfmt jq; do + if ! command -v "$dependency" >/dev/null 2>&1; then + echo "❌ $dependency is required to build the standalone binary" >&2 + return 1 + fi + done + + # shfmt distinguishes real shell comments from `#` text inside heredocs. Keep + # the executable shebang and the tiny source-boundary markers: they make the + # flattened artifact navigable and let the build tests detect duplicate + # embeds. Everything else remains available in the repository source. + local jq_filter='walk( + if type == "object" and has("Comments") then + .Comments |= map(select( + ((.Text // "") | startswith("!")) or + ((.Text // "") | test("^ src/.*\\.sh$")) + )) + else . end + )' + if ! shfmt -ln=bash --to-json <"$file" \ + | jq "$jq_filter" \ + | shfmt -i 2 --from-json >"$temp_file"; then + rm -f "$temp_file" + echo "❌ Failed to strip comments from $file" >&2 + return 1 + fi + + mv "$temp_file" "$file" + chmod u+x "$file" +} + function build::assert_valid_syntax() { local file=$1 diff --git a/tests/acceptance/bashunit_upgrade_test.sh b/tests/acceptance/bashunit_upgrade_test.sh index d59fb995..889545cb 100644 --- a/tests/acceptance/bashunit_upgrade_test.sh +++ b/tests/acceptance/bashunit_upgrade_test.sh @@ -28,7 +28,7 @@ function tear_down_after_script() { } function set_up() { - ./build.sh "$TMP_DIR" >/dev/null + _BASHUNIT_BUILD_SKIP_COMMENT_STRIP=true ./build.sh "$TMP_DIR" >/dev/null if [[ "$ACTIVE_INTERNET" == true ]] && [[ "$HAS_GIT" == true ]]; then LATEST_VERSION="$(bashunit::helper::get_latest_tag)" else diff --git a/tests/unit/project/build_test.sh b/tests/unit/project/build_test.sh index 61a3fabb..0ebaacc9 100644 --- a/tests/unit/project/build_test.sh +++ b/tests/unit/project/build_test.sh @@ -19,6 +19,14 @@ function build_dependencies() { (cd "$ROOT_DIR" && bash -c 'source ./build.sh && build::dependencies') } +function build_unoptimized() { + (cd "$ROOT_DIR" && _BASHUNIT_BUILD_SKIP_COMMENT_STRIP=true bash build.sh "$1") +} + +function build_optimizer_is_available() { + command -v shfmt >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 +} + # Every src file the dev entrypoint sources (except dev-only helpers) must also be # bundled by build.sh, otherwise its functions are missing from the distributable # single-file binary (regressions: bench #0.31.0, watch #735). @@ -93,6 +101,40 @@ function test_build_embed_docs_fails_on_missing_markers() { assert_contains "echo hi" "$(cat "$file")" } +function test_build_strip_comments_preserves_heredocs_shebang_and_source_markers() { + if ! build_optimizer_is_available; then + bashunit::skip "shfmt and jq are required for standalone optimization" + return + fi + + local file + file=$(bashunit::temp_file) + cat >"$file" <<'EOF' +#!/usr/bin/env bash +# src/example.sh +# ordinary source comment +value="# quoted value" +cat <<'DOC' +# Markdown heading + # indented example comment +DOC +printf '%s\n' "$value" # inline source comment +EOF + + (cd "$ROOT_DIR" && bash -c 'source ./build.sh && build::strip_comments "$1"' _ "$file") + + assert_same "#!/usr/bin/env bash" "$(head -n 1 "$file")" + assert_file_contains "$file" "# src/example.sh" + assert_file_not_contains "$file" "ordinary source comment" + assert_file_not_contains "$file" "inline source comment" + assert_file_contains "$file" '# Markdown heading' + assert_file_contains "$file" ' # indented example comment' + assert_file_contains "$file" 'value="# quoted value"' + local exit_code=0 + bash -n "$file" || exit_code=$? + assert_equals 0 "$exit_code" +} + # build::process_file emits a file's body and *then* recurses into its `source` # lines, so an aggregator holding anything else at top level would run that code # before its dependencies in the built binary but after them in dev mode. @@ -273,12 +315,28 @@ function test_built_binary_contains_no_source_lines() { local build_dir build_dir=$(bashunit::temp_dir) - (cd "$ROOT_DIR" && bash build.sh "$build_dir") >/dev/null 2>&1 + build_unoptimized "$build_dir" >/dev/null 2>&1 assert_file_exists "$build_dir/bashunit" assert_equals "0" "$(grep -c '^source ' "$build_dir/bashunit")" } +function test_built_binary_stays_below_500_kib() { + if ! build_optimizer_is_available; then + bashunit::skip "shfmt and jq are required for standalone optimization" + return + fi + + local build_dir + build_dir=$(bashunit::temp_dir) + + (cd "$ROOT_DIR" && bash build.sh "$build_dir") >/dev/null 2>&1 + + local bytes + bytes=$(wc -c <"$build_dir/bashunit" | tr -d ' ') + assert_less_or_equal_than 512000 "$bytes" +} + function test_build_assert_valid_syntax_rejects_broken_file() { local file file=$(bashunit::temp_file) @@ -295,7 +353,7 @@ function test_built_binary_defines_watch_run() { local build_dir build_dir=$(bashunit::temp_dir) - (cd "$ROOT_DIR" && bash build.sh "$build_dir") >/dev/null 2>&1 + build_unoptimized "$build_dir" >/dev/null 2>&1 assert_file_exists "$build_dir/bashunit" assert_equals "1" "$(grep -c 'function bashunit::watch::run()' "$build_dir/bashunit")" @@ -305,7 +363,7 @@ function test_built_binary_embeds_each_src_file_exactly_once() { local build_dir build_dir=$(bashunit::temp_dir) - (cd "$ROOT_DIR" && bash build.sh "$build_dir") >/dev/null 2>&1 + build_unoptimized "$build_dir" >/dev/null 2>&1 local duplicated duplicated=$(grep -E '^# src/[a-z_0-9/]+\.sh$' "$build_dir/bashunit" | sort | uniq -d) @@ -319,7 +377,7 @@ function test_built_binary_defines_each_bashunit_function_exactly_once() { local build_dir build_dir=$(bashunit::temp_dir) - (cd "$ROOT_DIR" && bash build.sh "$build_dir") >/dev/null 2>&1 + build_unoptimized "$build_dir" >/dev/null 2>&1 # Scoped to the bashunit:: namespace on purpose: an unqualified `^function ` # also matches the example code inside the embedded docs/assertions.md heredoc.