Chore(ci): fail validation when the test run leaves the working tree dirty - #566
Open
AmaadMartin wants to merge 3 commits into
Open
Chore(ci): fail validation when the test run leaves the working tree dirty#566AmaadMartin wants to merge 3 commits into
AmaadMartin wants to merge 3 commits into
Conversation
added 3 commits
August 2, 2026 22:54
…dirty Tests that materialize files into process.cwd() clean up inside the it() body, so a failed assertion leaks the artifact. Nothing in CI inspected the working tree afterwards, which is how a stray root output.txt reached the repository (google#276). Add a step immediately after the test run that fails the job with a GitHub error annotation and the verbatim git status --porcelain listing when the tree is dirty. shell: bash keeps it portable across the ubuntu/windows/macos matrix, matching the idiom already used in cross-language-integration.yml.
The new working-tree gate turned the windows-latest leg red: the skill-script
integration tests left 112 untracked paths (scripts/hello*.{js,py,sh,ps1,bat},
references/) in the repository root.
UnsafeLocalCodeExecutor skipped input files when scanning its scratch
directory for script output by comparing File.name against the fs.readdir
entry. readdir reports platform separators, so on Windows 'scripts/hello.js'
never equalled 'scripts\hello.js' and every nested skill resource came back as
script output, which RunSkillScriptTool then materialized into process.cwd().
Record the materialized paths when the inputs are written and match on those
instead.
Pinned by a new unit test whose './assets/logo.txt' input is reported as
output on the old code on every platform.
Collapse the mutable Set plus if plus for accumulation into one map over materializeFiles' return value. CodeExecutionInput.inputFiles is a required File[], so the nullish guard it replaces was unreachable defensive code; every in-repo caller passes the field. Dropping it removes a branch v8 could never cover, taking the file's branch coverage from 86.15% to 87.30%.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Closes: #issue_number
Related: feat: skills: support script execution google/adk-js#276 — the PR whose local run left the stray 5-byte
output.txtat the repository root that is still tracked today.Problem: Several tests materialize real files into
process.cwd()(the repository root on CI) and delete them only on the happy path — thefs.unlinkcalls live at the end of theit()body, so any assertion that fails earlier leaks the artifact:process.cwd()tests/integration/tools/run_skill_script_tool_test.tsoutput_from_script.txt,output_from_script_2.txtfs.unlinkinside theit()bodytests/integration/tools/run_skill_inline_script_tool_test.tstest_output_<ts>.txt,test_inline_output_<ts>*.txtfs.unlinkinside theit()bodytests/integration/skills/script_js/agent_test.tsephemeral_entanglement.md,index.html,sketch.jsafterAllNothing in
validation.yamlinspects the working tree afterwards.npm run lintandnpm run format:checkonly glob**/*.ts, andtypedoc --emit nonewrites nothing, so a leaked artifact keeps the job green and can then begit add-ed and merged by accident. That is exactly how the rootoutput.txtgot in (google#276).Solution: two commits.
1.
chore(ci)— the gate. One step in.github/workflows/validation.yaml, immediately afterRun tests and check code coverageand beforeRun lint check, that fails the job whengit status --porcelainis non-empty, emitting a GitHub::error::annotation plus the verbatim porcelain listing so the offending paths are named. 12 lines of YAML, no new action or npm dependency, no new script.Design points worth reviewing:
shell: bash— GitHub defaultsrun:topwshonwindows-latest. Declaringbash(present on all three runner images) is what makes the same four lines work across the matrix; it reuses the idiom already in.github/workflows/cross-language-integration.yml. The Windows run log confirms it:shell: C:\Program Files\Git\bin\bash.EXE --noprofile --norc -e -o pipefail {0}.git status --porcelain, notgit diff --exit-code— untracked files are the primary leak signal andgit diffdoes not see them. The default-unormalbehaviour is kept deliberately: a leaked directory collapses to a single line instead of spamming thousands of paths (verified locally as scenario 6, and in CI where the leakedreferences/tree printed as one line).--ignoredis deliberately not passed —dist/,node_modules/,coverage/,.husky/_/and the fixture lockfiles are gitignored build/test output and must not trip the gate (verified, scenario 4 below).if: always()and nocontinue-on-error— if an earlier step failed the job is already red; running the gate afterwards would only muddy failure attribution.git clean,git checkout,git stashor auto-commit. Its only effect is the exit code.2.
fix— the pre-existing Windows leak the gate immediately caught. The gate was expected to be green on arrival;ubuntu-latestandmacos-latestwere, andwindows-latestwas not. It reported 112 dirty paths:references/plus 111 files underscripts/(hello.{js,py,sh,ps1,bat},fail.*,create_file.js,double.js, each with_2…_10collision suffixes).Root cause, in
core/src/code_executors/unsafe_local_code_executor.ts. After running a script, the executor scans its scratch directory and reports everything that is not the script itself as output, skipping the input files it materialized:relativeFilePathcomes fromfs.readdir(tempDir, {recursive: true}), which joins with the platform separator, whileFile.nameis always/-separated. On POSIX the two spellings coincide and the skip works; on Windows'scripts/hello.js' !== 'scripts\hello.js', so every nested skill resource was reported as script output, andRunSkillScriptToolthen materialized the whole set intoprocess.cwd()(run_skill_script_tool.ts:145) — on CI, the repository root; for a real user, wherever their agent runs. The_2…_10suffixes arematerializeFiles' collision renaming, one round per test case.The fix records the resolved path of each input at the moment it is materialized and matches on that instead, so the comparison no longer depends on separator spelling:
Deriving the set from
materializeFiles' return value uses the names it actually wrote, which are the post-collision-rename ones. Theif (params.codeExecutionInput.inputFiles)guard that used to wrap that call is gone:CodeExecutionInput.inputFilesis a requiredFile[]and all four in-repo callers pass it, so the guard was unreachable defensive code. Dropping it removes a branch v8 can never cover and takes the file's branch coverage from 86.15% to 87.30%.Scope note: this is a product bug, not a test bug, so it is fixed at the root rather than by adjusting the tests — but it is unrelated to the CI gate in every way except that the gate is what surfaced it, and the gate cannot land while it makes a matrix leg red. It is a separate commit for that reason. Deliberately still out of scope: deleting the root
output.txtand moving theprocess.cwd()writers ontofs.mkdtempdirectories; the gate is correct and green with or without that work, in either merge order. Nothing was added to.gitignore— ignoring a leaked artifact would defeat the gate. One adjacent latent defect is left alone: on Windows a genuine nested output file is still reported with a\-separatedFile.name; fixing that is a naming change with its own blast radius and no bearing on the leak.Collision check:
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000(163 open PRs), thengh pr diff --name-onlyon every plausible candidate. 23 open PRs touch.github/workflows/validation.yaml; grepping their diffs forgit status/porcelain/working tree/--exit-codefound no PR that adds a working-tree-clean gate. The nearest neighbours are #507, #467 and #345, which each add a narrowergit diff --exit-code -- package-lock.jsonlockfile-drift check attached to the install step — a different concern at a different point in the file. For the executor fix I also grepped the six open PRs touching the skill-script/executor area (#305, #353, #355, #410, #437, #516) for a separator orwin32fix and found none; #355 (fs.mkdtempscratch dir) touches the same function but a different statement. This PR therefore branches frommainrather than stacking.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
For the CI gate: no unit test, and none should be added — the change adds no TypeScript source line, so there is nothing for vitest to cover and the
vitest.config.tscoverage thresholds are unaffected. A test that re-read and parsedvalidation.yamlto assert the step exists would assert the file's own contents rather than any behaviour. The gate is verified by executing it (below).For the executor fix: one new test in
core/test/code_executors/unsafe_local_code_executor_test.ts,'should exclude nested input files from the returned output files'. It passes a nested input (scripts/hello.js, the shape that breaks on Windows) and a second spelling of the same idea (./assets/logo.txt), and asserts the returned output files are exactly['new_output.txt']. It runs the real subprocess — no mocks.npx vitest run --project unit:core core/test/code_executors/unsafe_local_code_executor_test.ts→ 19 passed.git stash push -- core/src/code_executors/unsafe_local_code_executor.ts) the new test FAILS on Linux:scripts/hello.jshalf of the test cannot fail on Linux by construction (the string comparison happens to work when the separator is/); that half is proven by thewindows-latestleg of this PR going from 112 dirty paths to clean.core/src/code_executors/unsafe_local_code_executor.tsfrom that file alone: 94.78% lines / 87.30% branches. No line or branch added by this change is uncovered — every remaining gap is pre-existing and platform-gated (the Windows.bat/.ps1extension selection at lines 103–113, theExit code Nfallback, the readdir error handler).core/test/tools/skills/+core/test/utils/file_utils_test.ts(84 passed), andtests/integration/tools/{run_skill_script_tool,run_skill_inline_script_tool,skills_registry_integration}_test.ts(19 passed, 4 skipped).npx eslintandnpx prettier --checkclean on both changed files. No suppression (any,@ts-expect-error,eslint-disable) was added anywhere in this PR.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
Every scenario below ran the real
run:body extracted from the committed.github/workflows/validation.yaml(not a retyped copy) underbash --noprofile --norc -eo pipefail, which is how GitHub invokes ashell: bashstep. To reproduce, from the repo root:The negative scenarios are the proof that the gate can actually fail: on a clean tree it exits 0 silently, and each kind of dirt flips it to 1 with the path named.
exit=0, no output: > output_from_script.txt(the real leak name fromrun_skill_script_tool_test.ts)?? output_from_script.txt::error::The test run left the working tree dirty. …then?? output_from_script.txt,exit=1printf '\n' >> README.md(modified tracked file)M README.mdM README.md,exit=1mkdir -p coverage && : > coverage/x.json(gitignored)exit=0, no output — confirms--ignoredis not passedmv output.txt …(deleted tracked file)D output.txtD output.txt,exit=1?? leaked_dir/,exit=1— confirms-unormalcollapsingEach scenario restored the tree afterwards (
rm,git checkout --,mvback);git status --porcelainwas empty again before the next one.Pipeline rehearsal on Linux (Node v22.22.2, npm 9.2.0), checking
git status --porcelainafter each stage:npm install→ tree clean (empty porcelain).npm run build→ tree clean.npx vitest run --project integration <the three suites that write into process.cwd()>→ all pass, tree clean, gateexit=0. Thescript_jsfixture's in-placenode_modules/is removed by its ownafterAll;git status --porcelain --ignoredon that directory is empty afterwards.npm run test:integration(the wholeintegrationproject — it contains every knownprocess.cwd()writer) →git status --porcelainempty afterwards, gateexit=0. Three suites failed locally for an unrelated environment reason (a mirroring npm registry makes the fixtures' in-placenpm installexceed the 60sbeforeAllbudget); the same suites pass individually and all three legs of CI run them clean.Note on
package-lock.json: installing through a mirroring registry rewrites theresolvedURLs and shows the lockfile as modified locally. Installing from the public registry leaves it byte-identical, which is what CI does — the lockfile islockfileVersion: 3and already carries thewin32-*anddarwin-*optional platform entries, so no churn occurred on any of the three runners.Three-OS CI confirmation: the new Check that tests left the working tree clean step ran (
completed/success, not skipped) onubuntu-latest,windows-latestandmacos-latest, and all threerun-testslegs are green. On the first commit it passed on ubuntu and macos and correctly failed on Windows with the 112-path listing described above, which is the end-to-end proof that the gate both fires and clears. The Windows leg also hit the knowntests/integration/app_loader/app_loader_test.ts40s discovery timeout once (unrelated to this diff — it fails the test step, before the gate step runs, and passed on re-run).Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.