diff --git a/.bazelrc b/.bazelrc index 84b287e..94ed411 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,4 +1,8 @@ # Use a symlink prefix to avoid clobbering the workspace root. build --symlink_prefix=.bazel/ # Disable Aspect Telemetry tracking. -common --repo_env=DO_NOT_TRACK=1 \ No newline at end of file +common --repo_env=DO_NOT_TRACK=1 +# Windows: rules_js's js_binary (markdownlint) needs a runfiles tree, and +# Windows builds only a runfiles manifest by default. +common --enable_platform_specific_config +build:windows --enable_runfiles diff --git a/.cursor/skills/versioning/SKILL.md b/.cursor/skills/versioning/SKILL.md deleted file mode 100644 index 499d448..0000000 --- a/.cursor/skills/versioning/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: versioning -description: >- - Bump bazel_utils module versions in lockstep (Rust-style 0.x or SemVer after - 1.0.0) and create the matching GitHub tag. Use when releasing, tagging, - bumping module(version), editing MODULE.bazel versions, or updating README - git_override tags. ---- - -# Versioning - -One release version for the whole repo. Even if only one language module changed, bump **every** module and the root `MODULE.bazel`, then tag GitHub. - -## Scheme - -Versions are `MAJOR.MINOR.PATCH`. `module(version = "0.1.0")` has **no** `v`. The git tag and `git_override(tag = …)` **do**: `v0.1.0`. - -If **major is 0** (Rust approach): - -- Breaking change → bump **minor** (`0.1.2` → `0.2.0`) -- Bugfix or new features (compatible) → bump **patch** (`0.1.0` → `0.1.1`) - -If **major > 0** (standard SemVer): - -- Breaking → **major** -- Compatible features → **minor** -- Bugfixes → **patch** - -Do not skip numbers. Do not jump to `1.0.0` unless the user explicitly wants a stable public API. - -**Breaking** means consumer-visible Starlark: renamed/removed rules or attrs, required new attrs, provider changes, `buf.toolchains` contract. Pinned tool upgrades are a patch (0.x) or minor (≥1.0) unless they force a breaking API/output change. - -Tool pins (Buf CLI via `buf.toolchains`, plugins via `protoc.plugin`, ruff) are -**not** this version. CLI hashes live in `buf/registry.bzl`; plugin hashes in -`protoc/plugins//registry.bzl`. The consumer selects the release tag. - -## Lockstep files - -Set the same `MAJOR.MINOR.PATCH` everywhere: - -- Root `MODULE.bazel`: `module(version = …)` and every `bazel_dep(name = "bazel_utils_*", version = …)` -- Each language module `module(version = …)`: `bazel/`, `buf/`, `core/`, `go/`, `markdown/`, `protoc/`, `python/`, `rust/` -- Each language module's `bazel_dep(name = "bazel_utils_core", version = …)` (except `core/` itself) -- `README.md`: “Current module version”, every example `bazel_dep(… version = …)`, every `git_override(… tag = "v…")` - -Refresh `MODULE.bazel.lock` if it changes. - -## Release - -After the version commit is on the default branch, create an annotated tag and push it (only when the user asked to release): - -```bash -git tag -a "vMAJOR.MINOR.PATCH" -m "vMAJOR.MINOR.PATCH" -git push origin "vMAJOR.MINOR.PATCH" -``` - -Examples: `v0.1.0`, `v0.1.1`, `v0.2.0`. Consumers pin that tag in `git_override`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52244df..33b4f8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,12 @@ name: CI +# Every public rule is exercised by //... (tests/, unit tests, lint) on all six +# supported platforms. Runs for pull requests and release tags, not every push. on: - push: - branches: [main] pull_request: + push: + tags: ["v*"] + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -12,48 +15,132 @@ concurrency: permissions: contents: read +env: + BAZELISK_VERSION: 1.29.0 + jobs: - ci: - name: Bazel - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - BAZELISK_VERSION: 1.29.0 - BAZELISK_SHA256: 5a408715e932c0250d28bd84555f12edbf70117de42f9181691c736eacc4a992 - # Portable caches only. Do not persist output_user_root (~/.cache/bazel): - # it includes sandbox, execroot, and a live Bazel server. - BAZEL_DISK_CACHE: /home/runner/.cache/bazel-disk - BAZEL_REPO_CACHE: /home/runner/.cache/bazel-repo + bazel: + name: Bazel (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - platform: linux-amd64 + runner: ubuntu-24.04 + bazelisk: bazelisk-linux-amd64 + bazelisk_sha256: 5a408715e932c0250d28bd84555f12edbf70117de42f9181691c736eacc4a992 + bazel: bazel + shell: bash + cache_root: ~/.cache/bazel-ci + - platform: linux-arm64 + runner: ubuntu-24.04-arm + bazelisk: bazelisk-linux-arm64 + bazelisk_sha256: e20e8b0f4f240091b7a55bf17b9398bd4f40ee70ae0208dff95dd4c445fb4010 + bazel: bazel + shell: bash + cache_root: ~/.cache/bazel-ci + - platform: darwin-amd64 + runner: macos-15-intel + bazelisk: bazelisk-darwin-amd64 + bazelisk_sha256: 16c3d7aa15323a9fb69f56c7ec5733ed18bedb786680d0ba13bb12a3c8083007 + bazel: bazel + shell: bash + cache_root: ~/.cache/bazel-ci + - platform: darwin-arm64 + runner: macos-15 + bazelisk: bazelisk-darwin-arm64 + bazelisk_sha256: cee851f726789227d5561004e9904a52be45c3efb56f8b38b6993d6adbaa0409 + bazel: bazel + shell: bash + cache_root: ~/.cache/bazel-ci + - platform: windows-amd64 + runner: windows-2025 + bazelisk: bazelisk-windows-amd64.exe + bazelisk_sha256: 092a8738d5b41aae7a85c42cc961b1034e3389aba43ffc20c0fabda7b43e095b + bazel: bazel.exe + # PowerShell, not Git Bash, so Bazel does not inherit Git's Unix tools. + shell: pwsh + cache_root: C:/bazel-ci + - platform: windows-arm64 + runner: windows-11-arm + bazelisk: bazelisk-windows-arm64.exe + bazelisk_sha256: 8bc42bd5d7857f18a21440b906469bb6c7cf91a7c72364d4b1e5ec56a76fe94f + bazel: bazel.exe + shell: pwsh + cache_root: C:/bazel-ci + defaults: + run: + shell: ${{ matrix.shell }} steps: + # Keep LF so buildifier, markdownlint, and `git diff` see the committed bytes. + - name: Disable CRLF conversion + shell: bash + run: git config --global core.autocrlf false + - name: Check out repository uses: actions/checkout@v7.0.1 - name: Install Bazelisk + shell: bash + env: + BAZELISK: ${{ matrix.bazelisk }} + BAZELISK_SHA256: ${{ matrix.bazelisk_sha256 }} + BAZEL_EXE: ${{ matrix.bazel }} run: | set -euo pipefail - curl -fsSL -o /tmp/bazelisk \ - "https://github.com/bazelbuild/bazelisk/releases/download/v${BAZELISK_VERSION}/bazelisk-linux-amd64" - echo "${BAZELISK_SHA256} /tmp/bazelisk" | sha256sum -c - sudo install -m 0755 /tmp/bazelisk /usr/local/bin/bazel + dir="${RUNNER_TEMP}/bazelisk" + mkdir -p "${dir}" + curl -fsSL -o "${dir}/${BAZEL_EXE}" \ + "https://github.com/bazelbuild/bazelisk/releases/download/v${BAZELISK_VERSION}/${BAZELISK}" + # macOS's BSD sha256sum cannot read a checklist from stdin, so + # compute the hash and compare it here. Hash stdin: with a Windows + # path argument, Git Bash's shasum prefixes the hash with `\`. + if command -v shasum >/dev/null; then + actual="$(shasum -a 256 < "${dir}/${BAZEL_EXE}" | awk '{print $1}')" + else + actual="$(sha256sum < "${dir}/${BAZEL_EXE}" | awk '{print $1}')" + fi + if [[ "${actual}" != "${BAZELISK_SHA256}" ]]; then + echo "bazelisk sha256 mismatch: got ${actual}, want ${BAZELISK_SHA256}" >&2 + exit 1 + fi + chmod +x "${dir}/${BAZEL_EXE}" + echo "${dir}" >> "${GITHUB_PATH}" - name: Cache Bazel uses: actions/cache@v6.1.0 with: path: | - ${{ env.BAZEL_DISK_CACHE }} - ${{ env.BAZEL_REPO_CACHE }} + ${{ matrix.cache_root }} ~/.cache/bazelisk - key: bazel-${{ runner.os }}-cas-${{ hashFiles('.bazelversion', 'MODULE.bazel.lock', 'markdown/pnpm-lock.yaml', 'python/uv.lock', 'go/go.sum') }} + ~/Library/Caches/bazelisk + ~/AppData/Local/bazelisk + key: bazel-${{ matrix.platform }}-${{ hashFiles('.bazelversion', 'MODULE.bazel.lock', 'markdown/pnpm-lock.yaml', 'go/go.sum') }} restore-keys: | - bazel-${{ runner.os }}-cas- + bazel-${{ matrix.platform }}- - - name: Configure Bazel caches + # Portable caches only. Do not persist output_user_root: it includes the + # sandbox, execroot, and a live Bazel server. + - name: Configure Bazel + shell: bash + env: + CACHE_ROOT: ${{ matrix.cache_root }} run: | set -euo pipefail - mkdir -p "${BAZEL_DISK_CACHE}" "${BAZEL_REPO_CACHE}" + root="${CACHE_ROOT/#\~/${HOME}}" + mkdir -p "${root}/disk" "${root}/repo" { - echo "common --disk_cache=${BAZEL_DISK_CACHE}" - echo "common --repository_cache=${BAZEL_REPO_CACHE}" + if [[ "${RUNNER_OS}" == Windows ]]; then + # Short output root: Windows MAX_PATH. + echo "startup --output_user_root=C:/b" + # No host bash: bazel_utils runs its scripts with busybox-w32, so + # anything that still reaches for Bazel's shell fails loudly. + echo "common --shell_executable=C:/no-host-bash/bash.exe" + fi + echo "common --disk_cache=${root}/disk" + echo "common --repository_cache=${root}/repo" } >> "${HOME}/.bazelrc" - name: Build @@ -62,7 +149,20 @@ jobs: - name: Test run: bazel test //... --keep_going --test_output=errors - # Stop the server so repository/disk caches are idle before actions/cache tars them. + # `bazel run` targets: each must run and leave the checkout unchanged. + - name: Run buildifier_format + run: bazel run //:format + + - name: Run buf_format + run: bazel run //tests/buf:format + + - name: Run ruff_format + run: bazel run //tests/python:format + + - name: Check formatters left no changes + run: git diff --exit-code + + # Stop the server so repository/disk caches are idle before actions/cache saves them. - name: Stop Bazel if: always() run: bazel shutdown diff --git a/BUILD.bazel b/BUILD.bazel index ce9b31f..e745deb 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -35,6 +35,16 @@ test_suite( ], ) +test_suite( + name = "go_tests", + tests = ["@bazel_utils_go//:go_list_patterns_test"], +) + +test_suite( + name = "protoc_tests", + tests = ["@bazel_utils_protoc//plugins:catalog_test"], +) + test_suite( name = "buf_tests", tests = [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 07998b5..5c4fd39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,101 @@ patch; breaking Starlark API changes bump the minor. ## [Unreleased] +## [0.3.0] - 2026-10-09 + +### Removed + +- Cursor rules (`.cursor/`). Maintainer notes (versioning, the 2-day + release quarantine, pin locations, Windows/ash rules, testing) are in + `CLAUDE.md`. +- **Breaking:** `bazel_utils_python` `pip_audit_test`; use `uv_audit_test`. + The module no longer ships pip-audit, its `uv.lock`, `pyproject.toml`, the + `bazel_utils_pypi` hub, or a Python toolchain, so no Python interpreter is + downloaded and Windows needs no bash for it. + +### Added + +- `bazel_utils_python`: `uv_audit_test` runs `uv audit --frozen` on the + consumer `uv.lock` (all groups and extras, OSV) with the pinned prebuilt + uv. `flags` are `uv audit` flags (`--ignore`, `--no-dev`, `--no-group`, …) + and the binary override is `uv`. `uv audit` is a uv preview feature; the + wrapper opts in with `--preview-features audit-command`. +- `bazel_utils_protoc`: `protoc-gen-protovalidate-buffa` catalog version + v0.10.2 (optional and oneof strings get the plain-string format and length + rules, `IGNORE_IF_ZERO_VALUE` covers field CEL and predefined rules, custom + rule paths keep the extension's package and messages) for linux, darwin, + and windows on amd64 and arm64. +- `protoc-gen-buffa`, `protoc-gen-buffa-packaging`, `protoc-gen-connect-rust`: + windows-arm64 entries that use the x64 build (no aarch64 asset upstream; + Windows 11 on ARM runs it under emulation). +- CI runs on all six platforms (linux, darwin, windows on amd64 and arm64) + for pull requests and release tags. Each job builds and tests `//...` and + runs the `*_format` targets, which must leave the checkout unchanged. + Windows jobs set a missing `--shell_executable`, so any use of a host bash + fails. +- `//tests` fixtures for every public rule: `buf_deps`, `buf_module`, + `buf_generate` with all nine prebuilt plugins, `buf_plugin`, + `buf_lint_test`, `buf_format`, `golangci_test`, `govulncheck_test`, + `ruff_test`, `ruff_format`, `uv_audit_test`, `cargo_audit_test` + (buildifier and markdownlint run on the repo itself). Unit tests for + `go_list_patterns` and for the plugin catalogs (`catalog_test`: every + version lists all six platforms, and one sha256 names exactly one URL). + +### Fixed + +- `protoc-gen-grpc-gateway` v2.31.0 windows-arm64 pinned protoc-gen-openapiv2's + sha256. Bazel's repository cache is keyed by sha256, so when openapiv2 was + fetched first, Windows arm64 builds silently got the openapiv2 binary under + the grpc-gateway name. It now pins its own hash from the release's + `checksums.txt`. +- `golangci_test`, `govulncheck_test`: run in the `go.mod` directory, with + `dirs` relative to it. A `go.mod` below the repo root (`//go:go.mod`) failed + with "directory prefix … does not contain main module" unless a root + `go.work` listed it. +- Windows: no host bash. `bazel_utils_core` downloads busybox-w32 + FRP-6075 (amd64, arm64). `*_test` and `*_format` rules (buildifier, buf + lint/format, golangci-lint, govulncheck, ruff, uv audit, cargo-audit, + markdownlint) return a `.bat` launcher that runs the wrapper with + `busybox sh` (Bazel on Windows does not run a `.bash` file), and + `buf_module` / `buf_generate` run their scripts with it instead of + `run_shell`. Wrapper scripts are ash-compatible (`;` PATH on Windows, + workspace search stops at `C:/`). `bazel_utils_core` now depends on + `bazel_lib` (batch runfiles lookup). Go wrappers point `%LocalAppData%` and + `%USERPROFILE%` at the test tmpdir when Bazel's Windows test env lacks them + (Go and golangci-lint keep their caches there). `markdownlint_test` needs + `build:windows --enable_runfiles`: rules_js's `js_binary` needs a runfiles + tree. Wrappers find the workspace from the runfiles manifest entry for + `MODULE.bazel`, which points into the checkout: a runfiles tree on Windows + holds a copy, and its directory is not the checkout. +- Windows: prebuilt binaries (buf, protoc plugins, ruff, golangci-lint, + cargo-audit) have a `.exe` output instead of `.bin`, so `bazel run` works + and `buf_generate` puts `.exe` plugins on PATH. `buf_plugin` keeps the + wrapped binary's extension. +- `protoc.plugin` no longer needs host Python. Zip releases (Windows builds of + protoc-gen-go, protoc-gen-connect-go, protoc-gen-protovalidate-buffa) are + downloaded by the repository rule and extracted at build time with the + hermetic bsdtar from `tar.bzl` 0.10.9, only for the platform in use. + Bazel 9.2's own zip reader still crashes on protobuf-go's zip comment. + +### Changed + +- Raised every language module and the aggregator to `0.3.0` (lockstep; + minor because `pip_audit_test` was removed). +- `protoc-gen-protovalidate-buffa` pin from v0.10.1 to v0.10.2. +- `bazel_utils_go`: rules_go from 0.63.0 to 0.64.1. +- `bazel_utils_python`: ruff from 0.16.9 to 0.16.10. +- `bazel_utils_python`: uv 0.12.23 is fetched directly from its GitHub + release (`python/uv.MODULE.bazel`, sha256s from the release's + `sha256.sum`), like ruff, instead of through aspect_rules_py's `uv_bin` + (default 0.11.6). The module no longer depends on aspect_rules_py, whose + 1.12.1 host repository fails on Windows (`Unsupported platform windows`) + and broke analysis of the whole build there. +- `bazel_utils_markdown`: aspect_rules_js from 3.4.1 to 3.5.1, rules_nodejs + from 6.7.5 to 6.7.6, Node from 24.18.0 to 24.21.0, and pnpm from 11.20.0 + to 12.10.1. Both are newer than the rules' catalogs, so Node's sha256s + (`node_repositories`) come from nodejs.org `SHASUMS256.txt` and pnpm's + `pnpm_version_integrity` from npm. `package.json` `packageManager` from pnpm 12.6.0 to 12.10.1. + ## [0.2.14] - 2026-10-07 ### Added @@ -223,7 +318,8 @@ Initial tagged release. Language modules for Bazel workspaces: Pin modules with `git_override` at tag `v0.1.0`. `bazel_utils_core` is a transitive dependency, not a consumer API. -[unreleased]: https://github.com/sonalect/bazel_utils/compare/v0.2.13...HEAD +[unreleased]: https://github.com/sonalect/bazel_utils/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/sonalect/bazel_utils/compare/v0.2.14...v0.3.0 [0.2.14]: https://github.com/sonalect/bazel_utils/compare/v0.2.13...v0.2.14 [0.2.13]: https://github.com/sonalect/bazel_utils/compare/v0.2.12...v0.2.13 [0.2.12]: https://github.com/sonalect/bazel_utils/compare/v0.2.11...v0.2.12 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a2a9930 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,150 @@ +# bazel_utils: notes for maintaining this repo + +Starlark helpers shipped as separate Bazel modules (`bazel/`, `buf/`, `core/`, +`go/`, `markdown/`, `protoc/`, `python/`, `rust/`). The root module is a dev +aggregator that pulls them in with `local_path_override`; consumers use +`git_override` at a release tag. `README.md` documents the public API, and +`CHANGELOG.md` records every change. + +## Principles + +- **Prebuilt binaries only.** Tools come from upstream release assets, + pinned by version and sha256. Do not build tools from source, do not commit + binaries, and do not publish our own binaries. +- **Nothing on the host.** No host bash, Python, unzip, or Go/Rust toolchain. + On Windows, scripts run with busybox-w32 (`@bazel_utils_core//:busybox`), + and zips are extracted with bsdtar from `tar.bzl`. +- **Six platforms.** linux, darwin, and windows on amd64 and arm64. A public + rule that does not work on one of them is a bug. + +## Updating a pinned tool + +1. **Quarantine: a release must be at least 2 days old.** Measure from its + publish time in UTC (`gh release view -R --json publishedAt`, + npm `time`, or the BCR commit date) against `date -u`. If it is younger, + pin the newest release that is older and say when the newer one clears. +2. Check the publisher (`--json author`) against earlier releases. A change + needs an explanation before the pin is used. +3. Take the sha256 from the upstream checksum file (`sha256.sum`, + `checksums.txt`, `SHA256SUMS`, `*.sha256`). Download every asset you pin + and check it against that file. +4. Update the pin, README (the plugin tables' "Catalog versions" column lists + every catalog version, not only the pin), and CHANGELOG. + A wrong hash usually fails the download, but a hash copied from **another** + asset does not: Bazel's repository cache is keyed by sha256 and serves + that other file. `@bazel_utils_protoc//plugins:catalog_test` rejects a + sha256 pinned for two URLs; check other catalogs by hand. +5. Rebuild the lockfile and run the checks under "Testing". + +Where pins live: + +| Tool | File | +| -------------- | ------------------------------------------------------------------------------ | +| Buf CLI | `buf/registry.bzl` (+ fallback in `buf/MODULE.bazel`) | +| protoc plugins | `protoc/plugins//registry.bzl` (+ fallback pin in `protoc/MODULE.bazel`) | +| buildifier | `bazel/buildifier.MODULE.bazel` | +| golangci-lint | `go/golangci.MODULE.bazel` | +| govulncheck | `go/go.mod` | +| ruff | `python/ruff.MODULE.bazel` | +| uv | `python/uv.MODULE.bazel` | +| cargo-audit | `rust/cargo_audit.MODULE.bazel` | +| Node, pnpm | `markdown/MODULE.bazel` + `markdown/package.json` `packageManager` | +| busybox-w32 | `core/MODULE.bazel` | +| bazelisk (CI) | `.github/workflows/ci.yml` matrix | + +When a version is newer than a rule set's built-in catalog, pin it ourselves: +Node uses `node_repositories` with the sha256s from `SHASUMS256.txt`, and pnpm +uses `pnpm_version_integrity` from npm. + +When only a binary is needed, fetch it with `http_archive`/`http_file` and a +`native_binary` (like ruff and uv), not through another rule set's module +extension. Extensions run repository rules on the host, and some fail on +Windows hosts and break the whole build there (aspect_rules_py 1.12.1 did). + +## Adding a protoc plugin + +- Add `protoc/plugins//registry.bzl` with all six platforms. If upstream + has no windows-arm64 build, reuse the x64 `.exe` with a comment (Windows 11 + on ARM emulates it). +- `.zip` assets are only downloaded; `protoc/plugins/unzip.bzl` extracts them + at build time. Bazel 9.2's zip reader crashes on zip comments (protobuf-go). +- Register it in `protoc/plugins/defs.bzl` and `protoc/plugins/BUILD.bazel`, + re-export it under `buf/protoc/plugins/`, and add it to the root + `//:protoc_plugins` filegroup, both README tables, and + `tests/buf/buf.gen.all.yaml` plus `//tests/buf:plugins`. + +## Shell scripts and Windows + +Wrapper scripts (`*.bash`) and `buf_module`/`buf_generate` actions run with +bash on Linux/macOS and with busybox-w32 `sh` (ash) on Windows: + +- Test/run rules get the executable from `launcher()` and attrs from + `LAUNCHER_ATTRS` (`core/internal/launcher.bzl`); on Windows that is a `.bat` + that runs the script. Shell actions use `run_shell_action()` with + `SHELL_ACTION_ATTRS`, never `ctx.actions.run_shell`. +- Native binaries need a `.exe` output on Windows: `native_binary(out = select( + {"@bazel_utils_core//:windows": "x.exe", "//conditions:default": "x.bin"}))`. +- Keep scripts ash-compatible: + - ash expands **both** sides of `[[ A && B ]]` before testing, so guard + every variable with `${VAR:-}` under `set -u`; + - the PATH separator is `$_PATHSEP` (`;` when `uname -s` is `Windows_NT`); + - a loop that walks up with `dirname` must stop when the parent equals the + path (`C:/`), not only at `/`; + - no `cygpath` or MSYS assumptions. +- Bazel's Windows test env is minimal: no `%LocalAppData%` or `%USERPROFILE%`. + Point tool caches at `$TEST_TMPDIR` (see `go/go_sdk_env.bzl`, uv, cargo). +- rules_js's `js_binary` needs a runfiles tree, so Windows needs + `build:windows --enable_runfiles` (set in `.bazelrc`); our own wrappers + also work with manifest-only runfiles. On Windows the runfiles tree holds copies, not + symlinks, so resolve source files (the workspace marker) through the + manifest (`_rf_source`), not through the tree. +- Check ash compatibility locally with `busybox sh