Skip to content

Commit d632dae

Browse files
authored
fix(sandbox): eliminate SSH transport race causing flaky E2E tests (#69)
1 parent 12a49e9 commit d632dae

12 files changed

Lines changed: 219 additions & 24 deletions

File tree

‎architecture/build-containers.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ Container builds use Docker BuildKit with local cache directories:
386386
- `build/scripts/docker-build-component.sh` stores per-component caches in `.cache/buildkit/<component>`.
387387
- `build/scripts/docker-build-cluster.sh` stores the cluster image cache in `.cache/buildkit/cluster`.
388388
- `mise run python:build:multiarch` stores per-platform wheel caches in `.cache/buildkit/python-wheels/<platform>` for local builds when using a `docker-container` buildx driver.
389-
- Rust-heavy Dockerfiles use BuildKit cache mounts for cargo registry, cargo target, and sccache local disk directories, keyed by image name and `TARGETARCH`, with `sharing=locked` to prevent concurrent cache corruption in parallel CI builds. The cargo target mount gives cargo a persistent `target/` directory for true incremental rebuilds on source-only changes. sccache uses memcached in CI (`SCCACHE_MEMCACHED_ENDPOINT`) and falls back to the local disk cache mount for local dev builds, providing a second layer of caching at the compilation unit level.
389+
- Rust-heavy Dockerfiles use BuildKit cache mounts for cargo registry, cargo target, and sccache local disk directories. Cargo target cache mounts are keyed by image name, `TARGETARCH`, and a computed scope hash derived from `Cargo.lock` plus a Rust toolchain hint, with `sharing=locked` to prevent concurrent cache corruption in parallel CI builds. This reduces reuse of stale `target/` artifacts across dependency or toolchain changes while preserving incremental rebuilds within a compatible scope. sccache uses memcached in CI (`SCCACHE_MEMCACHED_ENDPOINT`) and falls back to the local disk cache mount for local dev builds, providing a second layer of caching at the compilation unit level.
390390
- When the active buildx driver is `docker` (not `docker-container`), local cache import/export flags are skipped automatically because the docker driver cannot export local caches. In CI, cache export is also skipped.
391391
- For local single-arch builds, the scripts auto-select a builder with the native `docker` driver (matching the active Docker context) so images land directly in the Docker image store without slow tarball export.
392392

‎build/python.toml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,36 @@ run = """
1717
#!/usr/bin/env bash
1818
set -euo pipefail
1919
20+
sha256_16() {
21+
if command -v sha256sum >/dev/null 2>&1; then
22+
sha256sum "$1" | awk '{print substr($1, 1, 16)}'
23+
else
24+
shasum -a 256 "$1" | awk '{print substr($1, 1, 16)}'
25+
fi
26+
}
27+
28+
sha256_16_stdin() {
29+
if command -v sha256sum >/dev/null 2>&1; then
30+
sha256sum | awk '{print substr($1, 1, 16)}'
31+
else
32+
shasum -a 256 | awk '{print substr($1, 1, 16)}'
33+
fi
34+
}
35+
2036
PLATFORMS=${DOCKER_PLATFORMS:-linux/amd64,linux/arm64}
2137
CARGO_VERSION=${NAVIGATOR_CARGO_VERSION:-}
2238
if [ -z "$CARGO_VERSION" ]; then
2339
CARGO_VERSION=$(uv run python build/scripts/release.py get-version --cargo)
2440
fi
2541
42+
LOCK_HASH=$(sha256_16 Cargo.lock)
43+
RUST_SCOPE=${RUST_TOOLCHAIN_SCOPE:-rustup-stable}
44+
CACHE_SCOPE_INPUT="v1|python-wheels|base|${LOCK_HASH}|${RUST_SCOPE}"
45+
CARGO_TARGET_CACHE_SCOPE=$(printf '%s' "$CACHE_SCOPE_INPUT" | sha256_16_stdin)
46+
2647
VERSION_ARGS=(
2748
--build-arg "NAVIGATOR_CARGO_VERSION=${CARGO_VERSION}"
49+
--build-arg "CARGO_TARGET_CACHE_SCOPE=${CARGO_TARGET_CACHE_SCOPE}"
2850
)
2951
3052
if ! docker buildx inspect multiarch >/dev/null 2>&1; then
@@ -108,19 +130,41 @@ run = """
108130
#!/usr/bin/env bash
109131
set -euo pipefail
110132
133+
sha256_16() {
134+
if command -v sha256sum >/dev/null 2>&1; then
135+
sha256sum "$1" | awk '{print substr($1, 1, 16)}'
136+
else
137+
shasum -a 256 "$1" | awk '{print substr($1, 1, 16)}'
138+
fi
139+
}
140+
141+
sha256_16_stdin() {
142+
if command -v sha256sum >/dev/null 2>&1; then
143+
sha256sum | awk '{print substr($1, 1, 16)}'
144+
else
145+
shasum -a 256 | awk '{print substr($1, 1, 16)}'
146+
fi
147+
}
148+
111149
CARGO_VERSION=${NAVIGATOR_CARGO_VERSION:-}
112150
OSXCROSS_IMAGE_REF=${OSXCROSS_IMAGE:-crazymax/osxcross:latest}
113151
if [ -z "$CARGO_VERSION" ]; then
114152
CARGO_VERSION=$(uv run python build/scripts/release.py get-version --cargo)
115153
fi
116154
155+
LOCK_HASH=$(sha256_16 Cargo.lock)
156+
RUST_SCOPE=${RUST_TOOLCHAIN_SCOPE:-rustup-stable}
157+
CACHE_SCOPE_INPUT="v1|python-wheels-macos|base|${LOCK_HASH}|${RUST_SCOPE}"
158+
CARGO_TARGET_CACHE_SCOPE=$(printf '%s' "$CACHE_SCOPE_INPUT" | sha256_16_stdin)
159+
117160
mkdir -p target/wheels
118161
119162
docker build \
120163
-f deploy/docker/Dockerfile.python-wheels-macos \
121164
--target wheels \
122165
--build-arg "OSXCROSS_IMAGE=${OSXCROSS_IMAGE_REF}" \
123166
--build-arg "NAVIGATOR_CARGO_VERSION=${CARGO_VERSION}" \
167+
--build-arg "CARGO_TARGET_CACHE_SCOPE=${CARGO_TARGET_CACHE_SCOPE}" \
124168
--output type=local,dest=target/wheels \
125169
.
126170

‎build/scripts/docker-build-component.sh‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,39 @@
1818
# IMAGE_REGISTRY - Registry prefix for image name (e.g. ghcr.io/org/repo)
1919
set -euo pipefail
2020

21+
sha256_16() {
22+
if command -v sha256sum >/dev/null 2>&1; then
23+
sha256sum "$1" | awk '{print substr($1, 1, 16)}'
24+
else
25+
shasum -a 256 "$1" | awk '{print substr($1, 1, 16)}'
26+
fi
27+
}
28+
29+
sha256_16_stdin() {
30+
if command -v sha256sum >/dev/null 2>&1; then
31+
sha256sum | awk '{print substr($1, 1, 16)}'
32+
else
33+
shasum -a 256 | awk '{print substr($1, 1, 16)}'
34+
fi
35+
}
36+
37+
detect_rust_scope() {
38+
local dockerfile="$1"
39+
local rust_from
40+
rust_from=$(grep -E '^FROM --platform=\$BUILDPLATFORM rust:[^ ]+' "$dockerfile" | head -n1 | sed -E 's/^FROM --platform=\$BUILDPLATFORM rust:([^ ]+).*/\1/' || true)
41+
if [[ -n "${rust_from}" ]]; then
42+
echo "rust-${rust_from}"
43+
return
44+
fi
45+
46+
if grep -q "rustup.rs" "$dockerfile"; then
47+
echo "rustup-stable"
48+
return
49+
fi
50+
51+
echo "no-rust"
52+
}
53+
2154
COMPONENT=${1:?"Usage: docker-build-component.sh <component> [variant] [extra-args...]"}
2255
shift
2356

@@ -98,11 +131,17 @@ if [[ -n "${SCCACHE_MEMCACHED_ENDPOINT:-}" ]]; then
98131
SCCACHE_ARGS=(--build-arg "SCCACHE_MEMCACHED_ENDPOINT=${SCCACHE_MEMCACHED_ENDPOINT}")
99132
fi
100133

134+
LOCK_HASH=$(sha256_16 Cargo.lock)
135+
RUST_SCOPE=${RUST_TOOLCHAIN_SCOPE:-$(detect_rust_scope "${DOCKERFILE}")}
136+
CACHE_SCOPE_INPUT="v1|${COMPONENT}|${VARIANT:-base}|${LOCK_HASH}|${RUST_SCOPE}"
137+
CARGO_TARGET_CACHE_SCOPE=$(printf '%s' "${CACHE_SCOPE_INPUT}" | sha256_16_stdin)
138+
101139
docker buildx build \
102140
${BUILDER_ARGS[@]+"${BUILDER_ARGS[@]}"} \
103141
${DOCKER_PLATFORM:+--platform ${DOCKER_PLATFORM}} \
104142
${CACHE_ARGS[@]+"${CACHE_ARGS[@]}"} \
105143
${SCCACHE_ARGS[@]+"${SCCACHE_ARGS[@]}"} \
144+
--build-arg "CARGO_TARGET_CACHE_SCOPE=${CARGO_TARGET_CACHE_SCOPE}" \
106145
-f "${DOCKERFILE}" \
107146
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
108147
--provenance=false \

‎build/scripts/docker-publish-multiarch.sh‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,39 @@
2222
# AWS_REGION - AWS region (default: us-west-2)
2323
set -euo pipefail
2424

25+
sha256_16() {
26+
if command -v sha256sum >/dev/null 2>&1; then
27+
sha256sum "$1" | awk '{print substr($1, 1, 16)}'
28+
else
29+
shasum -a 256 "$1" | awk '{print substr($1, 1, 16)}'
30+
fi
31+
}
32+
33+
sha256_16_stdin() {
34+
if command -v sha256sum >/dev/null 2>&1; then
35+
sha256sum | awk '{print substr($1, 1, 16)}'
36+
else
37+
shasum -a 256 | awk '{print substr($1, 1, 16)}'
38+
fi
39+
}
40+
41+
detect_rust_scope() {
42+
local dockerfile="$1"
43+
local rust_from
44+
rust_from=$(grep -E '^FROM --platform=\$BUILDPLATFORM rust:[^ ]+' "$dockerfile" | head -n1 | sed -E 's/^FROM --platform=\$BUILDPLATFORM rust:([^ ]+).*/\1/' || true)
45+
if [[ -n "${rust_from}" ]]; then
46+
echo "rust-${rust_from}"
47+
return
48+
fi
49+
50+
if grep -q "rustup.rs" "$dockerfile"; then
51+
echo "rustup-stable"
52+
return
53+
fi
54+
55+
echo "no-rust"
56+
}
57+
2558
# ---------------------------------------------------------------------------
2659
# Parse arguments
2760
# ---------------------------------------------------------------------------
@@ -115,6 +148,7 @@ resolve_dockerfile() {
115148
# so Rust compiles natively and only the final stage runs on the target arch.
116149
# ---------------------------------------------------------------------------
117150
echo "Building multi-arch component images..."
151+
LOCK_HASH=$(sha256_16 Cargo.lock)
118152
for component in sandbox server; do
119153
echo "Building ${IMAGE_PREFIX}${component} for ${PLATFORMS}..."
120154
BUILD_ARGS=""
@@ -125,6 +159,10 @@ for component in sandbox server; do
125159
BUILD_ARGS="${BUILD_ARGS} --build-arg SCCACHE_MEMCACHED_ENDPOINT=${SCCACHE_MEMCACHED_ENDPOINT}"
126160
fi
127161
DOCKERFILE=$(resolve_dockerfile "${component}")
162+
RUST_SCOPE=${RUST_TOOLCHAIN_SCOPE:-$(detect_rust_scope "${DOCKERFILE}")}
163+
CACHE_SCOPE_INPUT="v1|${component}|base|${LOCK_HASH}|${RUST_SCOPE}"
164+
CARGO_TARGET_CACHE_SCOPE=$(printf '%s' "${CACHE_SCOPE_INPUT}" | sha256_16_stdin)
165+
BUILD_ARGS="${BUILD_ARGS} --build-arg CARGO_TARGET_CACHE_SCOPE=${CARGO_TARGET_CACHE_SCOPE}"
128166
FULL_IMAGE="${REGISTRY}/${IMAGE_PREFIX}${component}"
129167
docker buildx build \
130168
--platform "${PLATFORMS}" \

‎crates/navigator-sandbox/src/lib.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,13 @@ pub async fn run_sandbox(
404404
let netns_fd = ssh_netns_fd;
405405
let ca_paths = ca_file_paths.clone();
406406
let provider_env_clone = provider_env.clone();
407+
408+
let (ssh_ready_tx, ssh_ready_rx) = tokio::sync::oneshot::channel();
409+
407410
tokio::spawn(async move {
408411
if let Err(err) = ssh::run_ssh_server(
409412
addr,
413+
ssh_ready_tx,
410414
policy_clone,
411415
workdir_clone,
412416
secret,
@@ -421,6 +425,28 @@ pub async fn run_sandbox(
421425
tracing::error!(error = %err, "SSH server failed");
422426
}
423427
});
428+
429+
// Wait for the SSH server to bind its socket before spawning the
430+
// entrypoint process. This prevents exec requests from racing against
431+
// SSH server startup when Kubernetes marks the pod Ready.
432+
match timeout(Duration::from_secs(10), ssh_ready_rx).await {
433+
Ok(Ok(Ok(()))) => {
434+
info!("SSH server is ready to accept connections");
435+
}
436+
Ok(Ok(Err(err))) => {
437+
return Err(err.context("SSH server failed during startup"));
438+
}
439+
Ok(Err(_)) => {
440+
return Err(miette::miette!(
441+
"SSH server task panicked before signaling ready"
442+
));
443+
}
444+
Err(_) => {
445+
return Err(miette::miette!(
446+
"SSH server did not start within 10 seconds"
447+
));
448+
}
449+
}
424450
}
425451

426452
#[cfg(target_os = "linux")]

‎crates/navigator-sandbox/src/ssh.rs‎

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,17 @@ use tracing::{info, warn};
2626

2727
const PREFACE_MAGIC: &str = "NSSH1";
2828

29-
#[allow(clippy::too_many_arguments)]
30-
pub async fn run_ssh_server(
29+
/// Perform SSH server initialization: generate a host key, build the config,
30+
/// and bind the TCP listener. Extracted so that startup errors can be forwarded
31+
/// through the readiness channel rather than being silently logged.
32+
async fn ssh_server_init(
3133
listen_addr: SocketAddr,
32-
policy: SandboxPolicy,
33-
workdir: Option<String>,
34-
handshake_secret: String,
35-
handshake_skew_secs: u64,
36-
netns_fd: Option<RawFd>,
37-
proxy_url: Option<String>,
38-
ca_file_paths: Option<(PathBuf, PathBuf)>,
39-
provider_env: HashMap<String, String>,
40-
) -> Result<()> {
34+
ca_file_paths: &Option<(PathBuf, PathBuf)>,
35+
) -> Result<(
36+
TcpListener,
37+
Arc<russh::server::Config>,
38+
Option<Arc<(PathBuf, PathBuf)>>,
39+
)> {
4140
let mut rng = OsRng;
4241
let host_key = PrivateKey::random(&mut rng, Algorithm::Ed25519).into_diagnostic()?;
4342

@@ -48,10 +47,41 @@ pub async fn run_ssh_server(
4847
config.keys.push(host_key);
4948

5049
let config = Arc::new(config);
51-
let ca_paths = ca_file_paths.map(Arc::new);
50+
let ca_paths = ca_file_paths.as_ref().map(|p| Arc::new(p.clone()));
5251
let listener = TcpListener::bind(listen_addr).await.into_diagnostic()?;
5352
info!(addr = %listen_addr, "SSH server listening");
5453

54+
Ok((listener, config, ca_paths))
55+
}
56+
57+
#[allow(clippy::too_many_arguments)]
58+
pub async fn run_ssh_server(
59+
listen_addr: SocketAddr,
60+
ready_tx: tokio::sync::oneshot::Sender<Result<()>>,
61+
policy: SandboxPolicy,
62+
workdir: Option<String>,
63+
handshake_secret: String,
64+
handshake_skew_secs: u64,
65+
netns_fd: Option<RawFd>,
66+
proxy_url: Option<String>,
67+
ca_file_paths: Option<(PathBuf, PathBuf)>,
68+
provider_env: HashMap<String, String>,
69+
) -> Result<()> {
70+
let (listener, config, ca_paths) = match ssh_server_init(listen_addr, &ca_file_paths).await {
71+
Ok(v) => {
72+
// Signal that the SSH server has bound the socket and is ready to
73+
// accept connections. The parent task awaits this before spawning
74+
// the entrypoint process, ensuring exec requests won't race
75+
// against server startup.
76+
let _ = ready_tx.send(Ok(()));
77+
v
78+
}
79+
Err(err) => {
80+
let _ = ready_tx.send(Err(err));
81+
return Ok(());
82+
}
83+
};
84+
5585
loop {
5686
let (stream, peer) = listener.accept().await.into_diagnostic()?;
5787
stream.set_nodelay(true).into_diagnostic()?;

‎crates/navigator-server/src/grpc.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,10 +1407,13 @@ fn is_valid_env_key(key: &str) -> bool {
14071407
/// Maximum number of attempts when establishing the SSH transport to a sandbox.
14081408
/// The sandbox SSH server may not be listening yet when the pod is marked Ready,
14091409
/// so we retry transient connection failures with exponential backoff.
1410-
const SSH_CONNECT_MAX_ATTEMPTS: u32 = 4;
1410+
const SSH_CONNECT_MAX_ATTEMPTS: u32 = 6;
14111411

14121412
/// Initial backoff duration between SSH connection retries (doubles each attempt).
1413-
const SSH_CONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(200);
1413+
const SSH_CONNECT_INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_millis(250);
1414+
1415+
/// Maximum backoff duration between SSH connection retries (caps exponential growth).
1416+
const SSH_CONNECT_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
14141417

14151418
/// Returns `true` if the gRPC status represents a transient SSH connection error
14161419
/// that is worth retrying (e.g. the sandbox SSH server is not yet listening).
@@ -1453,7 +1456,8 @@ async fn stream_exec_over_ssh(
14531456
let mut result = None;
14541457
for attempt in 0..SSH_CONNECT_MAX_ATTEMPTS {
14551458
if attempt > 0 {
1456-
let backoff = SSH_CONNECT_INITIAL_BACKOFF * 2u32.pow(attempt - 1);
1459+
let backoff = (SSH_CONNECT_INITIAL_BACKOFF * 2u32.pow(attempt - 1))
1460+
.min(SSH_CONNECT_MAX_BACKOFF);
14571461
warn!(
14581462
sandbox_id = %sandbox_id,
14591463
attempt = attempt + 1,

‎deploy/docker/Dockerfile.python-wheels‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ FROM base AS builder
2525
ARG TARGETARCH
2626
ARG BUILDARCH
2727
ARG NAVIGATOR_CARGO_VERSION
28+
ARG CARGO_TARGET_CACHE_SCOPE=default
2829

2930
ARG SCCACHE_MEMCACHED_ENDPOINT
3031

@@ -59,7 +60,7 @@ RUN mkdir -p crates/navigator-cli/src crates/navigator-core/src crates/navigator
5960
# The cargo-target mount gives cargo a persistent target/ dir for incremental rebuilds.
6061
RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \
6162
--mount=type=cache,id=cargo-git-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/git \
62-
--mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH},sharing=locked,target=/build/target \
63+
--mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \
6364
--mount=type=cache,id=sccache-python-wheels-${TARGETARCH},sharing=locked,target=/tmp/sccache \
6465
. cross-build.sh && cargo_cross_build --release -p navigator-cli 2>/dev/null || true
6566

@@ -83,7 +84,7 @@ RUN touch crates/navigator-cli/src/main.rs \
8384

8485
RUN --mount=type=cache,id=cargo-registry-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \
8586
--mount=type=cache,id=cargo-git-python-wheels-${TARGETARCH},sharing=locked,target=/root/.cargo/git \
86-
--mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH},sharing=locked,target=/build/target \
87+
--mount=type=cache,id=cargo-target-python-wheels-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \
8788
--mount=type=cache,id=sccache-python-wheels-${TARGETARCH},sharing=locked,target=/tmp/sccache \
8889
. cross-build.sh && \
8990
export_cross_env && \

‎deploy/docker/Dockerfile.python-wheels-macos‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ FROM python:${PYTHON_VERSION}-slim AS builder
99

1010
ARG TARGETARCH
1111
ARG NAVIGATOR_CARGO_VERSION
12+
ARG CARGO_TARGET_CACHE_SCOPE=default
1213

1314
ENV PATH="/root/.cargo/bin:/usr/local/bin:/osxcross/bin:${PATH}"
1415
ENV LD_LIBRARY_PATH="/osxcross/lib"
@@ -65,7 +66,7 @@ RUN mkdir -p crates/navigator-cli/src crates/navigator-core/src crates/navigator
6566
# Build dependencies only (cached unless Cargo.toml/lock changes).
6667
RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \
6768
--mount=type=cache,id=cargo-git-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/git \
68-
--mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH},sharing=locked,target=/build/target \
69+
--mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \
6970
cargo build --release --target aarch64-apple-darwin -p navigator-cli 2>/dev/null || true
7071

7172
# Copy actual source code and Python packaging files.
@@ -90,7 +91,7 @@ RUN touch crates/navigator-cli/src/main.rs \
9091

9192
RUN --mount=type=cache,id=cargo-registry-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/registry \
9293
--mount=type=cache,id=cargo-git-python-wheels-macos-${TARGETARCH},sharing=locked,target=/root/.cargo/git \
93-
--mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH},sharing=locked,target=/build/target \
94+
--mount=type=cache,id=cargo-target-python-wheels-macos-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \
9495
if [ -n "${NAVIGATOR_CARGO_VERSION:-}" ]; then \
9596
sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${NAVIGATOR_CARGO_VERSION}"'"/}' Cargo.toml; \
9697
fi && \

0 commit comments

Comments
 (0)