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
2 changes: 1 addition & 1 deletion .github/act/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# Usage (real local CI, e.g. the Quality gates job):
# act -W .github/workflows/ci.yml -j quality \
# --container-architecture linux/amd64 \
# -P ubuntu-latest=bitty-act:latest
# -P ubuntu-24.04=bitty-act:latest
#
# Keep the Rust channel in sync with rust-toolchain.toml when it changes.
FROM catthehacker/ubuntu:act-latest
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@ jobs:
toolchain: ${{ steps.rust-channel.outputs.channel }}
components: rustfmt, clippy

# act lacks the Actions runtime cache credentials. Keep the compiler
# wrapper and use bounded disk caches under the persisted target mount;
# each shard owns a directory because disk caches support one server.
# A nonempty GHA namespace enables that backend even when disabled.
- name: Configure local sccache backend
if: env.ACT
run: |
{
echo "SCCACHE_GHA_ENABLED=false"
echo "SCCACHE_GHA_VERSION="
echo "SCCACHE_DIR=$CARGO_TARGET_DIR/sccache-${{ matrix.shard }}"
echo "SCCACHE_CACHE_SIZE=1G"
} >> "$GITHUB_ENV"

# CTX-0971 R4: sccache server on the GHA backend (shares dep units with
# the linux-x11/linux-wayland jobs via the same SCCACHE_GHA_VERSION
# namespace). Kept alongside rust-cache below; posts stats automatically.
Expand All @@ -140,6 +154,10 @@ jobs:
# CTX-0971 R4 sharding: lint/check steps run once (shard 1) so the two
# shards do not duplicate clippy/fmt/packaging work. Test steps below
# are partitioned or split across both shards.
- name: Check local Quality dispatch and cache setup
if: matrix.shard == 1
run: bash scripts/tests/ci-local.test.sh

- name: Check Rust formatting
if: matrix.shard == 1
run: cargo fmt --all -- --check
Expand Down
4 changes: 4 additions & 0 deletions crates/bitty-runtime/src/component/wsl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,11 @@ pub fn windows_path_to_guest(windows: &str) -> Result<String, WslError> {
{
let drive = bytes[0].to_ascii_lowercase() as char;
let remainder = windows[3..].replace('\\', "/");
// scratch-paths-exempt: intentional WSL guest contract path, not a host path.
let guest = if remainder.is_empty() {
format!("/mnt/{drive}")
} else {
// scratch-paths-exempt: intentional WSL guest contract path, not a host path.
format!("/mnt/{drive}/{remainder}")
};
if guest.len() > WSL_PATH_MAX_BYTES {
Expand Down Expand Up @@ -385,6 +387,7 @@ pub fn guest_mnt_to_windows(guest: &str) -> Result<String, WslError> {
"unmappable guest path {guest:?} (want 1..={WSL_PATH_MAX_BYTES} bytes, no NUL or control characters)"
)));
}
// scratch-paths-exempt: intentional WSL guest contract path, not a host path.
let Some(rest) = guest.strip_prefix("/mnt/") else {
return Err(WslError::UnmappablePath(format!(
"unmappable guest path {guest:?} (only /mnt/<drive>/... has a Windows spelling; use the Windows-to-guest direction for spawn)"
Expand All @@ -394,6 +397,7 @@ pub fn guest_mnt_to_windows(guest: &str) -> Result<String, WslError> {
let drive = parts.next().unwrap_or("");
let remainder = parts.next().unwrap_or("");
if drive.len() != 1 || !drive.bytes().all(|b| b.is_ascii_lowercase()) {
// scratch-paths-exempt: intentional WSL guest contract path, not a host path.
return Err(WslError::UnmappablePath(format!(
"unmappable guest path {guest:?} (want /mnt/<lowercase-drive-letter>/...)"
)));
Expand Down
1 change: 1 addition & 0 deletions crates/bitty-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ mod tests {
));
std::fs::create_dir_all(&dir).expect("temp dir must build");
let script = dir.join("probe.sh");
// pty-gate-exempt: inert script fixture for elf_interp; never executed.
std::fs::write(&script, "#!/bin/sh\nexit 0\n").expect("fixture must write");
assert_eq!(linux::elf_interp(&script), None);
let _ = std::fs::remove_dir_all(&dir);
Expand Down
40 changes: 35 additions & 5 deletions crates/bitty-terminal/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,8 @@ pub fn component_usage() -> String {
/// Long help (`bitty extension --help`).
#[must_use]
pub fn component_help_text() -> String {
"bitty extension — CLI-first native binary extension management (local class, no execution)\n\
concat!(
"bitty extension — CLI-first native binary extension management (local class, no execution)\n\
\n\
usage: bitty extension <verb> [args] [flags]\n\
\n\
Expand Down Expand Up @@ -891,14 +892,16 @@ pub fn component_help_text() -> String {
\x20 distro (no --distribution flag). A selected distro with absent WSL\n\
\x20 fails closed before any staging (exit 1): install WSL2 from\n\
\x20 the Store (inbox builds use --status fallback, unversioned) or run\n\
\x20 without --distro. Path mapping (Windows to guest for spawn):\n\
\x20 drive-absolute X:\\... maps to /mnt/<lower-x>/... and WSL UNC\n\
\x20 without --distro. Path mapping (Windows to guest for spawn):\n",
// scratch-paths-exempt: intentional WSL help contract path example, not a host path.
"\x20 drive-absolute X:\\... maps to /mnt/<lower-x>/... and WSL UNC\n\
\x20 \\\\wsl$\\<distro>\\... maps to guest-absolute /...; UNC paths bind\n\
\x20 to their launch distro (cross-distro exec fails closed); device\n\
\x20 paths, drive-relative, relative, and other UNC spellings fail closed\n\
\x20 (exit 2, no silent mangling). Backslash rewriting happens only inside\n\
\x20 path mapping; general argv elements pass through discrete with spaces\n\
\x20 preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n\
\x20 path mapping; general argv elements pass through discrete with spaces\n",
// scratch-paths-exempt: intentional WSL help contract path example, not a host path.
"\x20 preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n\
\x20 exists for diagnostics only; other guest paths have no Windows\n\
\x20 spelling. Guest env is WSLENV-gated (forwarded proxy/TLS/locale\n\
\x20 names only, secrets never, otherwise clean by design). Stdio and\n\
Expand Down Expand Up @@ -987,6 +990,7 @@ pub fn component_help_text() -> String {
\x20 bitty extension remove net 0.0.1\n\
\x20 bitty extension show-source net\n\
\x20 bitty extension show-source net 0.0.1"
)
.to_string()
}

Expand Down Expand Up @@ -3364,6 +3368,32 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}

#[test]
fn help_path_mapping_lines_keep_original_bytes() {
let help = component_help_text();
let lines: Vec<&[u8]> = help
.split_inclusive('\n')
.filter(|line| {
line.contains("drive-absolute X:")
|| line.contains("\\\\wsl$\\")
|| line.contains("preserved and metacharacters inert.")
})
.map(str::as_bytes)
.collect();

assert_eq!(
lines,
vec![
b"\x20\x20drive-absolute X:\\... maps to /mnt/<lower-x>/... and WSL UNC\n"
.as_slice(),
b"\x20\x20\\\\wsl$\\<distro>\\... maps to guest-absolute /...; UNC paths bind\n"
.as_slice(),
b"\x20\x20preserved and metacharacters inert. Reverse (/mnt/... to Windows)\n"
.as_slice(),
]
);
}

#[test]
fn list_is_empty_without_roots_or_components() {
let base = scratch("empty");
Expand Down
27 changes: 18 additions & 9 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,23 @@ ci-local *args:
# Bind the persistent target dir at both CARGO_TARGET_DIR and the repo's
# own target/ so gates that read target/debug/bitty see a container-built
# binary instead of the host's (the host glibc is newer than the image's).
exec act -W .github/workflows/ci.yml -j quality \
--pull=false --bind --container-architecture linux/amd64 \
-P ubuntu-latest=bitty-act:latest \
--container-options "-u ubuntu ${group_add[*]:-} -v $cache/cargo-registry:$ctr_cargo/registry -v $cache/cargo-git:$ctr_cargo/git -v $cache/target:/cache/target -v $cache/target:$root/target" \
--env HOME=$ctr_home --env CARGO_HOME=$ctr_cargo --env RUSTUP_HOME=$ctr_rustup \
--env CARGO_TARGET_DIR=/cache/target --env CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-$(nproc)}" \
${proxy_env[@]+"${proxy_env[@]}"} \
{{args}}
# Matrix entries share this bind mount, including external suite checkouts.
# act's --concurrent-jobs does not serialize entries of one matrix job;
# run each shard explicitly under the same repository lock instead.
for shard in 1 2; do
act -W .github/workflows/ci.yml -j quality --matrix "shard:$shard" \
--cache-server-addr 127.0.0.1 \
--pull=false --bind --container-architecture linux/amd64 \
-P ubuntu-24.04=bitty-act:latest \
--container-options "-u ubuntu ${group_add[*]:-} -v $cache/cargo-registry:$ctr_cargo/registry -v $cache/cargo-git:$ctr_cargo/git -v $cache/target:/cache/target -v $cache/target:$root/target" \
--env HOME=$ctr_home --env CARGO_HOME=$ctr_cargo --env RUSTUP_HOME=$ctr_rustup \
--env CARGO_TARGET_DIR=/cache/target --env CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-$(nproc)}" \
${proxy_env[@]+"${proxy_env[@]}"} \
{{args}}
done

ci-local-test:
bash scripts/tests/ci-local.test.sh

pty-gate:
./scripts/check-pty-gated-tests.sh
Expand Down Expand Up @@ -313,7 +322,7 @@ commit-check message:
@cp commitlint.config.ts target/dev-tools/commitlint.config.ts
@msg="$(realpath "{{message}}")" && cd target/dev-tools && bunx --bun commitlint --edit "$msg"

check: fmt-check clippy test supply-chain supply-chain-test scratch-paths scratch-paths-test pty-gate test-support-gate status-drift status-drift-test docs-pin-test runtime-deps-test terminfo-check terminfo-test desktop-check desktop-test install-smoke-test install-scripts-test unix-bundle-test component-dist-test macos-dmg-test windows-installer-test verify-unix-bundle-dispatch-test rust-channel-test binary-arch-test workflow-publish-test m1-matrix-test compat-matrix-test compat-freshness-test real-render-soak-test dogfood-session-test actionlint markdownlint
check: fmt-check clippy test supply-chain supply-chain-test scratch-paths scratch-paths-test pty-gate test-support-gate status-drift status-drift-test docs-pin-test runtime-deps-test terminfo-check terminfo-test desktop-check desktop-test install-smoke-test install-scripts-test unix-bundle-test component-dist-test macos-dmg-test windows-installer-test verify-unix-bundle-dispatch-test rust-channel-test binary-arch-test workflow-publish-test m1-matrix-test compat-matrix-test compat-freshness-test real-render-soak-test dogfood-session-test ci-local-test actionlint markdownlint

# Parser-throughput baseline (CTX-0576, M1-11). Runs the deterministic
# headless parser benchmark over the committed VT/escape corpora and verifies
Expand Down
124 changes: 124 additions & 0 deletions scripts/tests/ci-local.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# ci-local.test.sh — CTX-1308 local Quality dispatch and cache regression tests.
set -euo pipefail

cd "$(dirname "$0")/../.."
scratch="${TMPDIR:-/tmp}/bitty"
mkdir -p "$scratch"
TMP="$(mktemp -d "$scratch/ci-local-test.XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
FAIL=0

fail() {
echo "FAIL: $*" >&2
FAIL=1
}

# Run the real recipe with only its external container commands replaced.
# A separate Git repository also isolates the repository-wide act lock.
mkdir -p "$TMP/bin" "$TMP/repo" "$TMP/cargo"
git init --quiet "$TMP/repo"
cat >"$TMP/bin/docker" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
[[ "$*" == 'image inspect bitty-act:latest' ]]
STUB
cat >"$TMP/bin/act" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" >>"$CI_LOCAL_TEST_ARGS"
if [[ -n "${CI_LOCAL_TEST_FAIL_SHARD:-}" && " $* " == *" shard:$CI_LOCAL_TEST_FAIL_SHARD "* ]]; then
exit 23
fi
exit "${CI_LOCAL_TEST_ACT_STATUS:-0}"
STUB
chmod +x "$TMP/bin/docker" "$TMP/bin/act"
export CI_LOCAL_TEST_ARGS="$TMP/args"

# Extract the shebang recipe so CI does not need a second task runner just
# to test dispatch. Substitute the variadic argument exactly as just does.
awk '
/^ci-local \*args:/ {recipe=1; next}
recipe && /^ / {sub(/^ /, ""); gsub(/\{\{args\}\}/, "--concurrent-jobs 1"); print; next}
recipe {exit}
' justfile >"$TMP/recipe.sh"
PATH="$TMP/bin:$PATH" CARGO_HOME="$TMP/cargo" BITTY_ACT_CACHE="$TMP/cache" \
bash -c 'cd "$1" && bash "$2"' _ "$TMP/repo" "$TMP/recipe.sh"

runner="$(awk '/^ quality:/{quality=1; next} quality && /^ runs-on:/{print $2; exit}' .github/workflows/ci.yml)"
grep -Fxq "$runner=bitty-act:latest" "$TMP/args" || fail 'Quality runner must map to the local image'
grep -Fq -- "-P $runner=bitty-act:latest" .github/act/Dockerfile || fail 'Dockerfile usage must match the Quality runner'
grep -Fxq 'quality' "$TMP/args" || fail 'dispatch must select the Quality job'
grep -Fxq -- '--concurrent-jobs' "$TMP/args" || fail 'caller arguments must reach act'
grep -Fxq -- '--bind' "$TMP/args" || fail 'local working tree must remain bind-mounted'
grep -Fq -- "$TMP/cache/target:/cache/target" "$TMP/args" || fail 'build/cache directory must persist'
grep -Fxq -- '--cache-server-addr' "$TMP/args" || fail 'local cache server must bind to proxy-exempt loopback'
grep -Fxq '127.0.0.1' "$TMP/args" || fail 'local cache endpoint must match NO_PROXY'
for shard in 1 2; do
grep -Fxq "shard:$shard" "$TMP/args" || fail "dispatch must explicitly execute shard $shard"
done
[[ "$(grep -Fxc 'quality' "$TMP/args")" == 2 ]] || fail 'both shards must run once in separate act invocations'

# An early shard failure must propagate, without starting the next shard.
rm "$TMP/args"
status=0
PATH="$TMP/bin:$PATH" CARGO_HOME="$TMP/cargo" BITTY_ACT_CACHE="$TMP/cache" \
CI_LOCAL_TEST_ACT_STATUS=23 bash -c 'cd "$1" && bash "$2"' _ "$TMP/repo" "$TMP/recipe.sh" || status=$?
[[ "$status" == 23 ]] || fail 'a shard failure must fail dispatch'
[[ "$(grep -Fxc 'quality' "$TMP/args")" == 1 ]] || fail 'a failed shard must stop dispatch'

# A passing first shard must not hide a failure in the second shard.
rm "$TMP/args"
status=0
PATH="$TMP/bin:$PATH" CARGO_HOME="$TMP/cargo" BITTY_ACT_CACHE="$TMP/cache" \
CI_LOCAL_TEST_FAIL_SHARD=2 bash -c 'cd "$1" && bash "$2"' _ "$TMP/repo" "$TMP/recipe.sh" || status=$?
[[ "$status" == 23 ]] || fail 'a second-shard failure must fail dispatch'
[[ "$(grep -Fxc 'quality' "$TMP/args")" == 2 ]] || fail 'second-shard failure must follow a passing first shard'

# A competing invocation must fail before starting any container command.
(
flock -x 9
rm "$TMP/args"
if PATH="$TMP/bin:$PATH" CARGO_HOME="$TMP/cargo" BITTY_ACT_CACHE="$TMP/cache" \
bash -c 'cd "$1" && bash "$2"' _ "$TMP/repo" "$TMP/recipe.sh" >"$TMP/locked.log" 2>&1; then
fail 'a held repository lock must reject a second run'
fi
[[ ! -e "$TMP/args" ]] || fail 'a rejected run must not invoke act'
grep -Fq 'another ci-local run' "$TMP/locked.log" || fail 'lock contention must be explained'
exit "$FAIL"
) 9>"$TMP/repo/.git/ci-local.lock" || FAIL=1

# Execute the local-only workflow setup, verifying the environment seen by
# the unchanged sccache action. Separate shard directories avoid disk-cache
# server races when act runs both matrix entries concurrently.
setup="$(awk '
/^ - name: Configure local sccache backend$/ {step=1; next}
step && /^ if:/ {next}
step && /^ run: \|/ {body=1; next}
body && /^ / {sub(/^ /, ""); print; next}
body {exit}
' .github/workflows/ci.yml)"
if [[ -z "$setup" ]]; then
fail 'local sccache setup step must exist'
else
awk '/^ - name: Configure local sccache backend$/ {getline; if ($0 != " if: env.ACT") exit 1; found=1} END {if (!found) exit 1}' \
.github/workflows/ci.yml || fail 'local backend must be limited to act'
for shard in 1 2; do
: >"$TMP/env-$shard"
printf '%s\n' "$setup" | sed "s/\${{ matrix.shard }}/$shard/g" |
GITHUB_ENV="$TMP/env-$shard" CARGO_TARGET_DIR="$TMP/target" bash -e
grep -Fxq 'SCCACHE_GHA_ENABLED=false' "$TMP/env-$shard" || fail 'act must disable only the GHA backend'
grep -Fxq 'SCCACHE_GHA_VERSION=' "$TMP/env-$shard" || fail 'a GHA namespace must not re-enable the remote backend locally'
grep -Fxq "SCCACHE_DIR=$TMP/target/sccache-$shard" "$TMP/env-$shard" || fail 'local disk cache must persist per shard'
grep -Eq '^SCCACHE_CACHE_SIZE=[1-9][0-9]*[MG]$' "$TMP/env-$shard" || fail 'disk cache must have an explicit size bound'
done
fi
quality="$(awk '/^ quality:/{quality=1; next} quality && /^ [a-z]/{exit} quality {print}' .github/workflows/ci.yml)"
grep -Fq 'SCCACHE_GHA_ENABLED: "true"' <<<"$quality" || fail 'remote Quality GHA caching must remain enabled'
grep -Fq 'RUSTC_WRAPPER: "sccache"' <<<"$quality" || fail 'Quality compiler caching must remain enabled'

if ((FAIL)); then
echo 'ci-local-test: FAIL' >&2
exit 1
fi
echo 'ci-local-test: OK'
Loading