From e1aa9cb4b378e671a0d5f966bcd69cc319394c83 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 7 Aug 2026 19:28:35 +0200 Subject: [PATCH 1/2] perf(build): strip standalone comments --- .github/CONTRIBUTING.md | 1 + .github/workflows/build.yml | 6 +++ .github/workflows/npm-publish.yml | 6 +++ CHANGELOG.md | 1 + ...dr-011-source-layout-and-build-pipeline.md | 6 ++- build.sh | 41 +++++++++++++++++++ tests/unit/project/build_test.sh | 40 ++++++++++++++++++ 7 files changed, 99 insertions(+), 2 deletions(-) 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..a2749afa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,12 @@ jobs: with: fetch-depth: 1 + - 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..92e30296 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -33,6 +33,12 @@ jobs: npm install -g npm@latest echo "npm version after upgrade: $(npm -v)" + - 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 69142dca..a6d7445f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Named snapshot assertions support multiple snapshots per test; mismatches show the resolved path and `--snapshot-update` hint (#986) ### Changed +- Build: standalone binaries omit source comments while preserving heredoc content and source markers, reducing the current artifact by about 22% - Core comparison assertions report missing required arguments as usage errors instead of comparing against empty values (#983) - Performance: Literal snapshots bypass placeholder regex processing unless they contain a placeholder (about 13x faster) (#985) - Performance: `assert_within_delta` uses fixed-point arithmetic for common values, with a `bc`/`awk` fallback for unsupported inputs (about 6.6x faster) (#979) 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..f59b3ce7 100755 --- a/build.sh +++ b/build.sh @@ -62,6 +62,11 @@ 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 `#`. + build::strip_comments "$out" + build::assert_valid_syntax "$out" } @@ -156,6 +161,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/unit/project/build_test.sh b/tests/unit/project/build_test.sh index 61a3fabb..fe79d927 100644 --- a/tests/unit/project/build_test.sh +++ b/tests/unit/project/build_test.sh @@ -93,6 +93,35 @@ 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() { + 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. @@ -279,6 +308,17 @@ function test_built_binary_contains_no_source_lines() { assert_equals "0" "$(grep -c '^source ' "$build_dir/bashunit")" } +function test_built_binary_stays_below_500_kib() { + 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) From cf8fc71f9b3824edf7ce2642d73fa47b136db091 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Fri, 7 Aug 2026 19:35:41 +0200 Subject: [PATCH 2/2] fix(ci): provision build optimizer --- .github/workflows/build.yml | 6 ++++++ .github/workflows/npm-publish.yml | 6 ++++++ build.sh | 6 +++++- tests/acceptance/bashunit_upgrade_test.sh | 2 +- tests/unit/project/build_test.sh | 26 +++++++++++++++++++---- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2749afa..e6c6f93c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,12 @@ 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: | diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 92e30296..94772c54 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -33,6 +33,12 @@ 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: | diff --git a/build.sh b/build.sh index f59b3ce7..c93ce563 100755 --- a/build.sh +++ b/build.sh @@ -65,7 +65,11 @@ function build::generate_bin() { # 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 `#`. - build::strip_comments "$out" + # 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" } 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 fe79d927..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). @@ -94,6 +102,11 @@ function test_build_embed_docs_fails_on_missing_markers() { } 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' @@ -302,13 +315,18 @@ 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) @@ -335,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")" @@ -345,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) @@ -359,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.