From 18f60e183418279e92b432d2879452dcabdc3a33 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:15:29 +0000 Subject: [PATCH 1/7] Bun.spawn/spawnSync, Bun.which: inherit live process.env instead of the startup snapshot When no env option is passed, Bun.spawn/Bun.spawnSync serialized the native DotEnv loader map (populated once from libc environ at startup), and both the default envp and the PATH used for argv0 lookup ignored any process.env.X = '...' or delete process.env.X done at runtime. Bun.which read PATH from the same stale map. Now the default environment is built by enumerating globalObject->processEnvObject() through the same append_envp_from_js path that an explicit env: {...process.env} already took, and Bun.which reads PATH from the same object. Runtime mutations to process.env, including prepending a directory to PATH, are now visible to the child and to argv0/which lookup. --- src/jsc/JSGlobalObject.rs | 7 ++ src/jsc/bindings/ZigGlobalObject.cpp | 5 ++ src/runtime/api/BunObject.rs | 7 +- src/runtime/api/bun/js_bun_spawn_bindings.rs | 60 ++++++++--------- test/js/bun/spawn/spawn-env.test.ts | 71 +++++++++++++++++++- 5 files changed, 115 insertions(+), 35 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index f266ad42429b..bb3cd0ed610a 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1167,6 +1167,12 @@ impl JSGlobalObject { ZigGlobalObject__makeNapiEnvForFFI(self) } + /// The live `process.env` JS object (forces the lazy init). Mutations made + /// via `process.env.X = ...` / `delete process.env.X` are visible here. + pub fn process_env_object(&self) -> JSValue { + ZigGlobalObject__processEnvObject(self) + } + #[inline] pub fn assert_on_js_thread(&self) { if cfg!(debug_assertions) { @@ -1679,6 +1685,7 @@ unsafe extern "C" { -> JSValue; safe fn ZigGlobalObject__makeNapiEnvForFFI(this: &JSGlobalObject) -> *mut c_void; + safe fn ZigGlobalObject__processEnvObject(this: &JSGlobalObject) -> JSValue; safe fn JSC__JSGlobalObject__bunVM(this: &JSGlobalObject) -> *mut c_void; safe fn JSC__JSGlobalObject__vm(this: &JSGlobalObject) -> *mut VM; diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b2b5535f071b..fc3eebf76524 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -1882,6 +1882,11 @@ extern "C" napi_env ZigGlobalObject__makeNapiEnvForFFI(Zig::GlobalObject* global return globalObject->makeNapiEnvForFFI(); } +extern "C" JSC::EncodedJSValue ZigGlobalObject__processEnvObject(Zig::GlobalObject* globalObject) +{ + return JSValue::encode(globalObject->processEnvObject()); +} + JSC_DEFINE_HOST_FUNCTION(jsFunctionPerformMicrotaskVariadic, (JSGlobalObject * globalObject, CallFrame* callframe)) { auto& vm = JSC::getVM(globalObject); diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index e3fbc234c67c..82b3f34ce7a9 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -614,9 +614,10 @@ pub(crate) fn which(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsRe return Ok(JSValue::NULL); } - // SAFETY: `transpiler.env` / `.fs` are process-lifetime singletons set during VM init. - let mut path_str = - ZigStringSlice::from_utf8_never_free(vm.env_loader().get(b"PATH").unwrap_or(b"")); + let mut path_str = match global_this.process_env_object().get(global_this, "PATH")? { + Some(v) if !v.is_undefined_or_null() => v.to_slice(global_this)?, + _ => ZigStringSlice::from_utf8_never_free(b""), + }; let mut cwd_str = ZigStringSlice::from_utf8_never_free(vm.top_level_dir()); if let Some(arg) = arguments.next_eat() { diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index a29989ce5300..03518fffeaa8 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -365,7 +365,6 @@ pub(crate) fn spawn_maybe_sync( // drops after `spawn_process` returns, freeing all argv/env allocations. let mut cstr_storage: Vec = Vec::new(); - let mut override_env = false; let mut env_array: Vec = Vec::new(); // SAFETY: `bun_vm()` returns the live VirtualMachine for this thread; it // outlives this call frame. @@ -383,9 +382,9 @@ pub(crate) fn spawn_maybe_sync( let mut lazy = false; let mut on_exit_callback = JSValue::ZERO; let mut on_disconnect_callback = JSValue::ZERO; - // `env_loader()` is the audited safe accessor for the per-VM DotEnv loader - // (process-lifetime; centralised non-null deref in `VirtualMachine`). - let mut path: &[u8] = jsc_vm.env_loader().get(b"PATH").unwrap_or(b""); + // Populated from the explicit `env:` option or, when absent, the live + // `process.env` object; both via `append_envp_from_js` below. + let mut path: &[u8] = b""; let mut argv: Vec = Vec::new(); let cmd_value: JSValue; let mut detached = false; @@ -601,7 +600,6 @@ pub(crate) fn spawn_maybe_sync( ); }; - override_env = true; // If the env object does not include a $PATH, it must disable path lookup for argv[0] let mut new_path: &[u8] = b""; // `JSObject` is an `opaque_ffi!` ZST handle; `opaque_ref` is the @@ -614,6 +612,20 @@ pub(crate) fn spawn_maybe_sync( &mut cstr_storage, )?; path = new_path; + } else { + // No explicit env: inherit the live `process.env` so runtime + // mutations (set/delete/PATH edits) reach the child. + let process_env = global_this.process_env_object(); + process_env.ensure_still_alive(); + if let Some(object) = process_env.get_object() { + append_envp_from_js( + global_this, + JSObject::opaque_ref(object), + &mut env_array, + &mut path, + &mut cstr_storage, + )?; + } } get_argv( @@ -894,6 +906,19 @@ pub(crate) fn spawn_maybe_sync( } } } else { + // No options object means no explicit env: inherit the live + // `process.env` so runtime mutations reach the child. + let process_env = global_this.process_env_object(); + process_env.ensure_still_alive(); + if let Some(object) = process_env.get_object() { + append_envp_from_js( + global_this, + JSObject::opaque_ref(object), + &mut env_array, + &mut path, + &mut cstr_storage, + )?; + } get_argv( global_this, cmd_value, @@ -908,31 +933,6 @@ pub(crate) fn spawn_maybe_sync( bun_output::scoped_log!(Subprocess, "spawn maxBuffer: {:?}", max_buffer); - // Owns the `K=V\0` storage when inheriting the parent env; the struct - // lives until spawn returns. - let mut inherited_env_storage: Option = None; - if !override_env && env_array.is_empty() { - // `Transpiler::env_mut()` is the audited safe `&mut Loader` accessor - // (per-VM DotEnv loader, valid for VM lifetime; centralised - // single-unsafe deref). `.map` is its `&'a mut Map` slot. - let envmap = match jsc_vm - .transpiler - .env_mut() - .map - .create_null_delimited_env_map() - { - Ok(m) => m, - Err(_) => return Err(global_this.throw_out_of_memory()), - }; - // Note: `as_slice()` *includes* the trailing null, so strip it; the - // common tail below re-appends one after the optional NODE_CHANNEL_* - // entries. - let entries = envmap.as_slice(); - env_array.extend_from_slice(&entries[..entries.len().saturating_sub(1)]); - inherited_env_storage = Some(envmap); - } - let _ = &inherited_env_storage; - for fd_index in 0..stdio.len() { if stdio[fd_index].can_use_memfd() { if stdio[fd_index].use_memfd(fd_index as u32) { diff --git a/test/js/bun/spawn/spawn-env.test.ts b/test/js/bun/spawn/spawn-env.test.ts index 79e7ae8a9f38..1e47eacff719 100644 --- a/test/js/bun/spawn/spawn-env.test.ts +++ b/test/js/bun/spawn/spawn-env.test.ts @@ -1,6 +1,73 @@ import { spawn } from "bun"; -import { test } from "bun:test"; -import { bunExe } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmodSync } from "node:fs"; +import { join } from "node:path"; + +// Bun.spawn / Bun.spawnSync with no `env:` option must inherit the *live* +// process.env, including runtime mutations, not the startup snapshot. +describe("default env inherits live process.env", () => { + const printEnv = isWindows + ? `[process.execPath, "-e", "process.stdout.write((process.env.BUN_TEST_SPAWN_ENV_SET ?? '[unset]') + ',' + (process.env.BUN_TEST_SPAWN_ENV_DEL ?? '[unset]'))"]` + : `["/bin/sh", "-c", "printf '%s,%s' \\"\${BUN_TEST_SPAWN_ENV_SET-[unset]}\\" \\"\${BUN_TEST_SPAWN_ENV_DEL-[unset]}\\""]`; + + for (const [label, call] of [ + ["Bun.spawnSync({cmd})", `Bun.spawnSync({ cmd: ${printEnv} }).stdout.toString()`], + ["Bun.spawnSync([cmd])", `Bun.spawnSync(${printEnv}).stdout.toString()`], + ["Bun.spawn({cmd})", `await (await Bun.spawn({ cmd: ${printEnv} }).stdout).text()`], + ] as const) { + test.concurrent(label, async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.env.BUN_TEST_SPAWN_ENV_SET = "runtime-value"; + delete process.env.BUN_TEST_SPAWN_ENV_DEL; + process.stdout.write(${call});`, + ], + env: { ...bunEnv, BUN_TEST_SPAWN_ENV_DEL: "startup-value" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect(stderr).toBe(""); + expect(stdout).toBe("runtime-value,[unset]"); + expect(exitCode).toBe(0); + }); + } + + test.concurrent.skipIf(isWindows)("PATH mutation is used for argv0 lookup and Bun.which", async () => { + using dir = tempDir("spawn-env-path", { + "bun_test_spawn_env_tool": "#!/bin/sh\nprintf found-the-tool\n", + }); + const toolDir = String(dir); + chmodSync(join(toolDir, "bun_test_spawn_env_tool"), 0o755); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.env.PATH = ${JSON.stringify(toolDir)} + ":" + process.env.PATH; + console.log(Bun.which("bun_test_spawn_env_tool") !== null); + console.log(Bun.spawnSync({ cmd: ["bun_test_spawn_env_tool"] }).stdout.toString()); + console.log(Bun.spawnSync(["bun_test_spawn_env_tool"]).stdout.toString());`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect(stderr).toBe(""); + expect(stdout).toBe("true\nfound-the-tool\nfound-the-tool\n"); + expect(exitCode).toBe(0); + }); +}); test("spawn env", async () => { const env = {}; From 4fe87960ac40dc884cd4924d685cd75614abf711 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:17:59 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- test/js/bun/spawn/spawn-env.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/test/js/bun/spawn/spawn-env.test.ts b/test/js/bun/spawn/spawn-env.test.ts index 1e47eacff719..564bc6ac396e 100644 --- a/test/js/bun/spawn/spawn-env.test.ts +++ b/test/js/bun/spawn/spawn-env.test.ts @@ -28,11 +28,7 @@ describe("default env inherits live process.env", () => { env: { ...bunEnv, BUN_TEST_SPAWN_ENV_DEL: "startup-value" }, stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toBe("runtime-value,[unset]"); expect(exitCode).toBe(0); @@ -58,11 +54,7 @@ describe("default env inherits live process.env", () => { env: bunEnv, stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toBe("true\nfound-the-tool\nfound-the-tool\n"); expect(exitCode).toBe(0); From cdae434df6962474fae2455f0c184db130d0338d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:37:16 +0000 Subject: [PATCH 3/7] Check exception after processEnvObject() lazy init createEnvironmentVariablesMap declares a THROW_SCOPE, so the first call to processEnvObject() (which forces the lazy init) must be followed by an exception check before the next throw scope. Wrap the extern in its own THROW_SCOPE with RETURN_IF_EXCEPTION and route the Rust side through from_js_host_call so zero maps to Err. --- src/jsc/JSGlobalObject.rs | 4 ++-- src/jsc/bindings/ZigGlobalObject.cpp | 6 +++++- src/runtime/api/BunObject.rs | 2 +- src/runtime/api/bun/js_bun_spawn_bindings.rs | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index bb3cd0ed610a..be88e8987135 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1169,8 +1169,8 @@ impl JSGlobalObject { /// The live `process.env` JS object (forces the lazy init). Mutations made /// via `process.env.X = ...` / `delete process.env.X` are visible here. - pub fn process_env_object(&self) -> JSValue { - ZigGlobalObject__processEnvObject(self) + pub fn process_env_object(&self) -> JsResult { + crate::from_js_host_call(self, || ZigGlobalObject__processEnvObject(self)) } #[inline] diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index fc3eebf76524..f8bc9f1eed08 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -1884,7 +1884,11 @@ extern "C" napi_env ZigGlobalObject__makeNapiEnvForFFI(Zig::GlobalObject* global extern "C" JSC::EncodedJSValue ZigGlobalObject__processEnvObject(Zig::GlobalObject* globalObject) { - return JSValue::encode(globalObject->processEnvObject()); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSC::JSObject* object = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(object); } JSC_DEFINE_HOST_FUNCTION(jsFunctionPerformMicrotaskVariadic, (JSGlobalObject * globalObject, CallFrame* callframe)) diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 82b3f34ce7a9..d7287990651a 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -614,7 +614,7 @@ pub(crate) fn which(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsRe return Ok(JSValue::NULL); } - let mut path_str = match global_this.process_env_object().get(global_this, "PATH")? { + let mut path_str = match global_this.process_env_object()?.get(global_this, "PATH")? { Some(v) if !v.is_undefined_or_null() => v.to_slice(global_this)?, _ => ZigStringSlice::from_utf8_never_free(b""), }; diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index 03518fffeaa8..fc346340adb9 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -615,7 +615,7 @@ pub(crate) fn spawn_maybe_sync( } else { // No explicit env: inherit the live `process.env` so runtime // mutations (set/delete/PATH edits) reach the child. - let process_env = global_this.process_env_object(); + let process_env = global_this.process_env_object()?; process_env.ensure_still_alive(); if let Some(object) = process_env.get_object() { append_envp_from_js( @@ -908,7 +908,7 @@ pub(crate) fn spawn_maybe_sync( } else { // No options object means no explicit env: inherit the live // `process.env` so runtime mutations reach the child. - let process_env = global_this.process_env_object(); + let process_env = global_this.process_env_object()?; process_env.ensure_still_alive(); if let Some(object) = process_env.get_object() { append_envp_from_js( From 92b1f55888b9aa813000666a1da702fa75314f9e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:38:54 +0000 Subject: [PATCH 4/7] test: assert combined {stdout, stderr, exitCode} for better failure diffs --- test/js/bun/spawn/spawn-env.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/js/bun/spawn/spawn-env.test.ts b/test/js/bun/spawn/spawn-env.test.ts index 564bc6ac396e..96c6b542ae59 100644 --- a/test/js/bun/spawn/spawn-env.test.ts +++ b/test/js/bun/spawn/spawn-env.test.ts @@ -29,9 +29,11 @@ describe("default env inherits live process.env", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("runtime-value,[unset]"); - expect(exitCode).toBe(0); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "runtime-value,[unset]", + stderr: "", + exitCode: 0, + }); }); } @@ -55,9 +57,11 @@ describe("default env inherits live process.env", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("true\nfound-the-tool\nfound-the-tool\n"); - expect(exitCode).toBe(0); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "true\nfound-the-tool\nfound-the-tool\n", + stderr: "", + exitCode: 0, + }); }); }); From 2cbcbe67fc0813bf2af6031344897fb3565f8e03 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:24:06 +0000 Subject: [PATCH 5/7] Extract inherit_process_env helper for the two no-env fallback sites --- src/runtime/api/bun/js_bun_spawn_bindings.rs | 44 ++++++++------------ 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index fc346340adb9..33e4c6336511 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -613,19 +613,7 @@ pub(crate) fn spawn_maybe_sync( )?; path = new_path; } else { - // No explicit env: inherit the live `process.env` so runtime - // mutations (set/delete/PATH edits) reach the child. - let process_env = global_this.process_env_object()?; - process_env.ensure_still_alive(); - if let Some(object) = process_env.get_object() { - append_envp_from_js( - global_this, - JSObject::opaque_ref(object), - &mut env_array, - &mut path, - &mut cstr_storage, - )?; - } + inherit_process_env(global_this, &mut env_array, &mut path, &mut cstr_storage)?; } get_argv( @@ -906,19 +894,7 @@ pub(crate) fn spawn_maybe_sync( } } } else { - // No options object means no explicit env: inherit the live - // `process.env` so runtime mutations reach the child. - let process_env = global_this.process_env_object()?; - process_env.ensure_still_alive(); - if let Some(object) = process_env.get_object() { - append_envp_from_js( - global_this, - JSObject::opaque_ref(object), - &mut env_array, - &mut path, - &mut cstr_storage, - )?; - } + inherit_process_env(global_this, &mut env_array, &mut path, &mut cstr_storage)?; get_argv( global_this, cmd_value, @@ -2094,6 +2070,22 @@ fn throw_command_not_found(global_this: &JSGlobalObject, command: &[u8]) -> JsEr global_this.throw_value(err.to_error_instance(global_this)) } +/// Populate `envp` / `path` from the live `process.env` JS object so runtime +/// mutations (set/delete/PATH edits) reach the child. +fn inherit_process_env( + global_this: &JSGlobalObject, + envp: &mut Vec, + path: &mut &[u8], + storage: &mut Vec, +) -> JsResult<()> { + let process_env = global_this.process_env_object()?; + process_env.ensure_still_alive(); + if let Some(object) = process_env.get_object() { + append_envp_from_js(global_this, JSObject::opaque_ref(object), envp, path, storage)?; + } + Ok(()) +} + /// `storage` receives ownership of every `K=V\0` line whose pointer is pushed /// into `envp` (and, for `PATH=`, sliced into `*path`); the caller's /// `Vec` is dropped after `spawn_process` returns. From b7a1626b3b0da361931035963b58dd1aac5fbc15 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:26:12 +0000 Subject: [PATCH 6/7] [autofix.ci] apply automated fixes --- src/runtime/api/bun/js_bun_spawn_bindings.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index 33e4c6336511..60a0a0610980 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -2081,7 +2081,13 @@ fn inherit_process_env( let process_env = global_this.process_env_object()?; process_env.ensure_still_alive(); if let Some(object) = process_env.get_object() { - append_envp_from_js(global_this, JSObject::opaque_ref(object), envp, path, storage)?; + append_envp_from_js( + global_this, + JSObject::opaque_ref(object), + envp, + path, + storage, + )?; } Ok(()) } From 3819377efbed49c812ab0f5e291224e16a11619e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:38:01 +0000 Subject: [PATCH 7/7] ci: retrigger