Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions adrs/adr-011-source-layout-and-build-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
45 changes: 45 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/acceptance/bashunit_upgrade_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 62 additions & 4 deletions tests/unit/project/build_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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")"
Expand All @@ -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)
Expand All @@ -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.
Expand Down
Loading