Fix: exit non-zero when adk deploy fails - #455
Open
AmaadMartin wants to merge 3 commits into
Open
Conversation
added 3 commits
August 1, 2026 05:50
deployToCloudRun printed the red "Failed to deploy to Cloud Run" banner and then let its promise resolve, and all three deploy action handlers caught the error, logged it and returned normally. The process therefore exited 0 on a failed deploy, so CI pipelines and shell scripts that gate on the exit status could never detect a broken deployment. Rethrow the original error from deployToCloudRun (mirroring deployToAgentEngine, whose finally-block cleanup still runs first) and set process.exitCode = 1 in the cloud_run and agent_engine/reasoning_engine handlers. process.exitCode rather than process.exit(1) so pending async writes from console.error and the winston console transport are flushed before Node exits.
…d error These three tests asserted only that the red banner was printed and let the resolved promise pass, which is exactly the bug: two of them were already named "should throw error ..." while their bodies awaited a resolution. They now assert the rejection alongside the existing banner assertion, and the spawn- failure test is renamed from "should handle spawn failures" -- "handle" was the euphemism for "swallow".
Adds a deployToCloudRun test pinning that the rethrow does not skip the finally block (temp folder removed, agentLoader.disposeAll() awaited), and three CLI tests asserting process.exitCode is 1 after a failed cloud_run or agent_engine deploy and untouched after a successful one. The CLI suite now saves and restores process.exitCode around every test, since leaking a 1 would fail the entire vitest run.
This was referenced Aug 1, 2026
Open
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
N/A — no public issue number exists for this report.
Problem:
adk deployexits with status 0 even when the deployment fails, so noshell script or CI job can gate on the exit code. There are two independent swallow
points:
deployToCloudRun()(dev/src/cli/deploy/cli_deploy_cloud_run.ts:214) catches, printsthe red
Failed to deploy to Cloud Run:banner, and falls through tofinallywithoutrethrowing — so the promise resolves on failure. Compare
deployToAgentEngine()(dev/src/cli/deploy/cli_deploy_agent_engine.ts:209), which isstructurally identical but ends its catch with
throw e;.logger.error(...), and return normally(
dev/src/cli/cli.ts:461forcloud_run,dev/src/cli/cli.ts:514insideregisterAgentEngineCommand). The action's promise resolves and Node exits 0.agent_engine/reasoning_engineare affected too, not justcloud_run: even thoughdeployToAgentEnginealready rethrows, the rethrow is caught and dropped one frame up inregisterAgentEngineCommand's handler. Fixing onlycloud_runwould leave the other twodeploy subcommands silently reporting success. All three deploy subcommands are in
scope.
Solution: three source lines.
dev/src/cli/deploy/cli_deploy_cloud_run.ts— appendthrow e;to the existing catch,keeping the banner and the
finallyblock untouched.finallystill runs tocompletion (temp folder removed,
agentLoader.disposeAll()awaited) before therethrown error propagates, and the original error object is rethrown — not wrapped,
not re-messaged.
dev/src/cli/cli.ts—process.exitCode = 1;in thedeploy cloud_runaction catch.dev/src/cli/cli.ts— the same one-line addition in theregisterAgentEngineCommandaction catch (covers both
agent_engineandreasoning_engine).Intentional, user-visible behaviour change
adk deploy cloud_run|agent_engine|reasoning_enginechanges from "always exit 0" to"exit 1 on failure". A script that (incorrectly) relied on the deploy command always
succeeding will now fail — that is the point of the fix. No public API changes:
deployToCloudRunis not exported fromdev/src/index.ts(whose only exports areAdkApiClientandAdkApiServer) and its only non-test caller isdev/src/cli/cli.ts,so no library consumer can observe the rethrow. No signature, option-shape, or dependency
changes.
Why
process.exitCode = 1and notprocess.exit(1)web(cli.ts:255) andapi_server(cli.ts:300) signal failure withprocess.exit(1).This change deliberately does not follow that precedent, and leaves those two handlers
alone:
process.exit()terminates immediately and truncates pending async writes. Bothconsole.errorto a pipe (exactly the CI case this bug is about) and the winstonconsole transport behind
AdkLoggerflush asynchronously, so a hard exit risksdiscarding the very error message the user needs.
disposeAll()are already awaited infinally— so setting the code and letting Nodeexit naturally is sufficient and strictly safer.
web/api_serverdiffer: apartially-started server holds handles open, so they need the hard exit.
cli_test.tsdrives these action handlers in-process viaprogram.parseAsync, soprocess.exit(1)would kill the vitest worker. Testing it would require stubbingprocess.exitinto a no-op, at which point the handler under test no longer behaveslike production.
process.exitCodeis directly assertable.The handler must keep catching rather than simply letting the rejection escape:
dev/src/cli_entrypoint.tscalls the synchronouscreateProgram().parse(...)insidea
try/catch, which can never observe an async action-handler rejection. Removing thecatch would produce an unhandled promise rejection with a raw stack trace instead of the
human-readable message.
Duplicated error message on the cloud_run failure path
The message is now printed twice on a failed
cloud_rundeploy: once as the redFailed to deploy to Cloud Run:banner fromdeployToCloudRun, and once asError deploying agent: <msg>from the CLI handler. This is not new behaviour beinginvented — it is exactly what the
agent_enginepath already does today, and keeping thebanner is a requirement of the fix. Neither message was deleted.
Collision check against sibling PRs
Ran
gh pr list --state open --limit 300and diffed the file lists of every open PR whosetitle mentioned deploy or exit. Eight PRs are adjacent (#447, #448, #449, #354, #363,
#307, #304, #286, #279) and four of them touch the same files (
dev/src/cli/cli.ts,dev/src/cli/deploy/cli_deploy_cloud_run.ts,dev/test/cli/cli_test.ts,dev/test/cli/cli_deploy_cloud_run_test.ts).gh pr diff <n> | grep -E '^\+.*(exitCode|throw e;|process\.exit)'returns nothing for all of them: none changesthe deploy exit-code behaviour, so they overlap textually but not semantically. This PR is
branched from
mainrather than stacked; expect only trivial merge conflicts in the testfiles.
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.
Existing tests whose assertions were flipped (commit
Test: flip the three deployToCloudRun tests that encoded the swallowed error, keptseparate from the source fix). These three encoded the bug — two were already named
should throw error ...while their bodies awaited a resolution — so they had to change:should throw error if package.json has no dependenciesawait deployToCloudRun(...)→await expect(...).rejects.toThrow(/No dependencies found in package.json/); existingconsoleErrorSpybanner assertion keptshould throw error if required npm packages are missing in package.json.rejects.toThrow(/Package "@google\/adk" is required but not found/); banner assertion keptshould handle spawn failures→ renamedshould reject when the gcloud deploy spawn fails.rejects.toThrow(/Command failed with exit code 1/); banner assertion kept. "handle" was the euphemism for "swallow"No test was deleted, skipped, or weakened. Every other test in both files is untouched —
in particular the success-path tests and the pre-
trythrows (Project is not specified,Region is not specified, and theit.each--set-env-vars/--remove-env-varsconflict cases), whose continued passing is the regression signal thatthe rethrow did not change the happy path.
New tests added (commit
Test: cover the deploy failure exit code and the failure-path cleanup):dev/test/cli/cli_deploy_cloud_run_test.ts—should still clean up temporary files when the deploy fails: spawn closes with code 1, and the test asserts the call rejectsand that
fs.rm('/tmp/test-deploy', {recursive: true, force: true})and theAgentLoaderdisposeAllspy were both called. Pins the invariant that the rethrowdoes not skip
finally.dev/test/cli/cli_test.ts—command: deploy cloud_run > should set a non-zero exit code when the deploy fails. Reaching the assertion afterawait parse(...)also provesthe rejection does not escape the handler as an unhandled promise rejection.
dev/test/cli/cli_test.ts—command: deploy cloud_run > should leave the exit code untouched on a successful deploy(asserts against the saved original, nottoBeUndefined()).dev/test/cli/cli_test.ts—command: deploy agent_engine > should set a non-zero exit code when the deploy fails.describe('CLI Entrypoint')now savesprocess.exitCodeinbeforeEachand restores itin
afterEach.process.exitCodeis global process state: a test that leaves it at 1would make the entire vitest run report failure even though every test passed.
New-line coverage: 100%. Per-line hit counts from
vitest run --coverage --coverage.include='dev/src/cli/cli.ts' --coverage.include='dev/src/cli/deploy/cli_deploy_cloud_run.ts'over the two test files:cli_deploy_cloud_run.ts:220(throw e;) 4 hits,cli.ts:4631 hit,cli.ts:5171 hit.No new branches were introduced. The residual file-level gaps (
cli.ts96.15% lines) arepre-existing lines these two test files never reach.
Mutation testing — every test was proven able to fail. Each source line was reverted
in turn, the targeted suite re-run, and the mutation then reverted:
throw e;fromcli_deploy_cloud_run.tsshould throw error if package.json has no dependencies,should throw error if required npm packages are missing in package.json,should reject when the gcloud deploy spawn fails,should still clean up temporary files when the deploy fails— all withAssertionError: promise resolved "undefined" instead of rejectingprocess.exitCode = 1;from thecloud_runhandlerdeploy cloud_run > should set a non-zero exit code when the deploy fails—AssertionError: expected undefined to be 1 // Object.is equality. Theagent_enginetest still passedprocess.exitCode = 1;from theregisterAgentEngineCommandhandlerdeploy agent_engine > should set a non-zero exit code when the deploy fails—expected undefined to be 1. Thecloud_runtest still passed, proving the two handlers are covered independently rather than one test masking bothprocess.exitCode = 1;out of thecloud_runcatch so it runs unconditionallyshould leave the exit code untouched on a successful deploy—expected 1 to be undefined. Proves the negative assertion has teethOther local validation on the pushed commit:
Integration tests: none added. This defect is entirely in the CLI's error plumbing, and
the
integrationvitest project does not exerciseadk deploy(it would need real GCPcredentials).
Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
No cloud access needed — any pre-
gcloudfailure inside thetryreproduces it.Before this change (same command, the two source files reverted to
main):After this change:
Message is not truncated when stderr is a pipe (the
process.exitCoderationale) —same command with
2>&1 | cat; both the banner and theError deploying agent:line arefully visible and the exit status is still 1:
agent_enginealso exits 1:A successful command still exits 0 —
node dist/esm/cli_entrypoint.js --versionprints1.5.0andexit=0. TheCleaning up temporary files... / Temporary files cleaned up.pair in every failure transcript above confirms the
finallyblock still runs before theerror propagates, so the temp directory is not leaked.
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.