Repository navigation
feat(deploy): send deno.json to the console on every deploy - #146
Conversation
`deno deploy` uploaded the files but never passed the deno.json contents to `apps.initiateCliRevision`, so the console fell back to the app's stored config and the `deploy` section only took effect once, when `deno deploy create` captured it. Every deno.json and deno.jsonc among the uploaded files is now sent as `denoJsonFiles`, and the console applies the app directory's `deploy` section as it does for GitHub deployments.
…ustom app directories
avocet-bot
left a comment
There was a problem hiding this comment.
Review — #146 feat(deploy): send deno.json to the console on every deploy
Reviewed head SHA: 3f99929c3555adb5e4140e4296f0b57f461d545a
Status: Draft PR, CI green (deno success, jsr success). No blocking issues found — non-blocking notes below.
Review skill evidence
This review was produced by invoking the Claude Code pr-review-toolkit:code-reviewer subagent (via the Task tool) against the four changed files at the reviewed head SHA, plus my own independent trace of the diff, the @deno/framework-detect@0.3.3 type contract, and the --json output contract in util.ts. The subagent completed a full pass (19 tool calls) and returned no findings at confidence ≥ 80.
What this PR does (background for a reader new to the codebase)
deploy-cli is the deno deploy command-line tool. When you run deno deploy, it tars up your project's files, hashes them, and hands the manifest to the Deno Deploy console (the backend) over a tRPC call named apps.initiateCliRevision; the console then builds and runs your app. Your project's deno.json/deno.jsonc can carry a deploy section that describes the build configuration (install/build commands, timeout, etc.).
The bug this PR fixes: the CLI uploaded the files but never passed the contents of deno.json to apps.initiateCliRevision. As a result, the console fell back to the build config it had stored when the app was first created (deno deploy create), so a deploy section in deno.json only took effect once, at creation time — not on subsequent deploys. GitHub-sourced deployments already re-read the deploy section on every deploy; this makes CLI (local) deployments behave the same way.
The change has three parts:
deploy/publish.ts— while streaming/hashing the uploaded files, it now also captures the text of everydeno.json/deno.jsoncinto a newdenoJsonFilesmap (keyed by normalized relative path), and additionally reads the rootdeno.json/deno.jsoncfrom disk even whendeploy.include/excludeleft it out of the upload. This map is sent alongsidemanifestinapps.initiateCliRevision. A small exported helperisDenoJson(path)does the filename match.deploy/create/flow.ts— the interactivecreateflow now printsDENO_JSON_PRECEDENCE_WARNINGwhen the user supplies build settings that adeno.jsondeploysection will override, and it auto-selects a timeout (skipping the prompt) whendeno.jsonalready decides it.deploy/create/mod.ts— the non-interactivecreatepath prints the same warning when--do-not-use-detected-build-configor--build-timeoutis given but the (possibly custom-directory)deno.jsonhas adeploysection; it callsdetectBuildConfigdirectly for a custom local app directory that is not a detected workspace member.
Key contract that makes the logic correct: in @deno/framework-detect@0.3.3, detectBuildConfig returns from: "deno.json" only when the deno.json actually has a deploy section (otherwise from: "detected"). So every from === "deno.json" guard in this PR is effectively "deno.json has a deploy section that will take precedence" — which is exactly the condition the warnings and the prompt-skip intend to key on.
Correctness assessment — no blockers
I traced the edge cases the diff introduces and they are handled:
- Path separators /
isDenoJson(publish.ts:31-33,:103).isDenoJsonslices after the last/. It is only ever called on paths already normalized to/(publish.ts:103), and the new unit test confirms it matchesdeno.json/deno.jsoncat any depth while rejectingpackage.json,deno.json.bak, andsrc/mydeno.json. The disk-read fallback usesjoin(rootPath, name)(:116), which correctly uses the OS separator for filesystem access while the map key stays the bare literaldeno.json/deno.jsonc— consistent with themanifestkeys the console already expects. - Upload-vs-fallback consistency / no TOCTOU (
publish.ts:113-120). The root fallback loopcontinues when a config is already present from the upload, so uploaded bytes are never overwritten by a second disk read. Adeno.jsonand adeno.jsoncget distinct keys, so no collision when both are present. detectBuildConfigfailure is non-fatal (mod.ts:258-263). It is awaited with.catch(() => undefined)and only forsource === "local", so a bad custom directory degrades to "no warning" rather than throwing.- No double-warning (
flow.ts:263, 277). ThefinalBuildConfig === buildConfigguards keep the precedence warning from firing twice across the "use detected" and "explicit timeout" branches. --jsonenvelope stays clean. The PR's new warnings are either gated by!options.json(mod.ts:251-252) or live increateFlow, which callsrequireInteractive()at its top (flow.ts:120-123) and therefore cannot run under--json.
Non-blocking notes
-
Intended new data-egress path is untested (
publish.ts:109-120). When a user deliberately excludes the rootdeno.jsonfrom the upload viadeploy.include/exclude, its full contents are now read from disk and transmitted inline asdenoJsonFiles["deno.json"]anyway; and every nesteddeno.json/deno.jsoncin the upload is sent, even though the comment notes the console only reads the app-directory one. This is the documented intent of commit475550fand the destination is the same trusted Deploy backend the user is already uploading their whole source tree to, so the privacy risk is low anddeno.jsonconventionally holds no secrets. Flagging only because it is a silent behavior change (an excluded file's contents now leave the machine) and no test covers the fallback or the "send all nested configs" behavior. A small test that drivespublish's collection with an excluded root config and a nested config would lock in the intent. -
Pre-existing ungated
console.warnunder--json(mod.ts:274-276, out of scope). The"No build configuration was detected in '…'."warning is not gated by!options.json, so under--jsonit writes non-JSON to stderr — the same contract sibling commit0b2870afixed for the new warning. This line is present verbatim onorigin/main, so it is pre-existing and not introduced here; worth a follow-up but not a blocker for this PR. -
Minor messaging overlap (not a bug). For a custom local app directory whose
deno.jsonhas adeploysection, passing--build-timeoutcan surface both the precedence warning and the pre-existing "No build configuration was detected" message. The outcome is functionally correct (the backend appliesdeno.jsonviadenoJsonFiles); the paired messages just read as mildly contradictory. -
Timeout placeholder is cosmetic (
flow.ts:275-280). Whendeno.jsondecides the timeout,buildTimeoutis set toAVAILABLE_BUILD_TIMEOUTS[0]purely to skip the prompt; the logged "X minutes" may not equal the valuedeno.jsonactually applies. The precedence warning already tells the userdeno.jsonwins, so this is low-impact UX only.
Draft context
The PR is a draft, explicitly held until denoland/deployng#3773 reaches prod (so a deno.json override does not drop the app's stored memory limit / cronsDisabled / executor). That upstream guard is the real gate on the behavior change; this CLI change is correct on its own terms. Known gaps (a deno.json in a non-root app directory excluded from the upload is not sent; the create warning does not probe custom GitHub app directories) are acknowledged in the PR description.
Recommendation: No blocking issues. Posting as a comment given the draft status and the non-blocking test-coverage/privacy notes above.
…app directories * look up the app's stored build directory, so an excluded deno.json there, or a config selected with --config, lands where the console reads it * the create warning checks custom app directories even without --build-timeout or --do-not-use-detected-build-config, since their build config is synthesized from flags
…ookup report normally
avocet-bot
left a comment
There was a problem hiding this comment.
Review (re-review of reworked head) — #146 feat(deploy): send deno.json to the console on every deploy
Reviewed head SHA: e794f13675cf608774a22274501e1cfd61aa1e22
Status: No longer a draft; CI green (deno success, jsr success). No blocking issues — one non-blocking robustness/test-coverage concern and a minor symmetry nit below.
This is a re-review: the head was substantially reworked since the earlier pass (7 new commits, publish.ts grew to +70/−3, and the approach changed from "send every nested deno.json plus a root fallback" to "send only the app directory's deno.json").
Review skill evidence
Produced by invoking the Claude Code pr-review-toolkit:code-reviewer subagent (via the Task tool) against the true PR delta (git diff origin/main...HEAD) at the reviewed head, plus my own trace. I additionally ran the new test locally (deno test --no-check -A tests/deno_json_files.test.ts → 1 passed) and confirmed the type-check failure is solely a missing generated wasm artifact (lib/rs_lib.js, a build step), unrelated to this PR.
What this PR does (background for a reader new to the codebase)
deploy-cli is the deno deploy command-line tool. Running deno deploy tars up and hashes your project files, hands a manifest to the Deno Deploy console (the backend) over a tRPC call apps.initiateCliRevision, then uploads the missing files; the console builds and runs the app. A project's deno.json/deno.jsonc can carry a deploy section describing the build configuration.
The bug being fixed: the CLI uploaded the files but never sent the contents of deno.json, so the console fell back to the build config it stored at app-creation time — the deploy section only took effect once, at deno deploy create. GitHub-sourced deploys already re-read it every deploy; this brings local (CLI) deploys in line.
How the reworked version does it (all in deploy/publish.ts):
- Learn the app's build directory. A new
appBuildDirectoryhelper queries the tRPCapps.getendpoint for the app's storedbuild_config.buildDirectoryand passes it through a newnormalizeBuildDirectoryhelper, which mirrors the console's normalization: it drops empty and.path segments and maps any..-escaping path to the root"". - Scope the config to that directory. During hashing, it captures only
${appDir}/deno.jsonor${appDir}/deno.jsonc(the app directory's config — "the console reads no others"). --configstand-in. If the user passed--config somefile, its contents are read from disk and sent under the${prefix}deno.jsonkey (the.jsonckey is deleted), so the console applies the explicitly selected config as the app directory's deno.json.- Excluded-config fallback. Otherwise, if
deploy.include/excludeleft the app directory's config out of the upload, it is read from disk (guarded by aninsideRootrelative-path check) so itsdeploysection still applies.
The create-flow changes (deploy/create/flow.ts, deploy/create/mod.ts) add a DENO_JSON_PRECEDENCE_WARNING and skip the timeout prompt when deno.json decides it; these are essentially unchanged from the earlier pass, except mod.ts now also warns when there is no detected workspace member (member === undefined, i.e. a custom app directory whose config is synthesized from flags).
Key contract that makes the warning logic correct: in @deno/framework-detect@0.3.3, detectBuildConfig returns from: "deno.json" only when the config actually has a deploy section, so every from === "deno.json" guard means "deno.json has a deploy section that will take precedence."
Correctness assessment — no blockers
I verified the pieces that could break and they hold:
- Write/read field agreement for the build directory. My first concern was that
createsendsbuildDirectorytop-level whilepublishreads it nested asbuild_config.buildDirectory. This is not a mismatch:createApp(deploy/create/mod.ts:429-434) nestsbuildDirectoryinside thebuildConfigobject it sends to the server, and the inner camelCase key matches the establishedbuild_config.frameworkPresetprecedent (deploy/apps.ts:25,130). Readingbuild_config.buildDirectoryback is consistent with the write path. normalizeBuildDirectorybehavior is directly unit-tested:""/"."→"","apps/web"/"./apps/web/"→"apps/web", backslash normalization"apps\\web"→"apps/web",".."-escapes ("../private","apps/../..","..\\private")→"", and the deliberate non-escape"..app"→"..app". I re-ran the suite locally: all 9 cases pass.--configpath readscontext.config, which is discovered and read at the top of every action viadiscoverConfig(config.ts) long beforepublish, so an unreadable path fails earlier; the key is normalized to${prefix}deno.jsonwith the.jsonckey deleted, so exactly one config is sent.- No
--jsonenvelope pollution. The newmod.tswarning is gated by!options.json; theflow.tswarnings live increateFlow, which callsrequireInteractive()first and so cannot run under--json. - Stream / concurrency. Awaiting
apps.getbefore draining thecountertee does not deadlock (the stream is lazy) and does not change the pre-existingtee()buffering. No new race. insideRootguard is defensive-redundant (sincenormalizeBuildDirectoryalready strips..,appDircan never escaperootPath), but harmless; Windows separators are handled consistently byrelative()/SEPARATOR.
Non-blocking finding
Silent fall-back to the repo root if apps.get ever omits build_config.buildDirectory, with no end-to-end coverage (deploy/publish.ts:56-57).
Intended behavior: for an app whose stored build directory is, say, apps/web, the CLI must send apps/web/deno.json so the console applies that directory's deploy section.
The concern: the server's apps.get response is a projection, and the only typed consumer in this repo, AppDetail (deploy/apps.ts:25), lists just build_config.frameworkPreset — not buildDirectory. publish.ts reads the field via a loose cast as { build_config?: { buildDirectory?: string } } followed by ?? "". If the server's serializer does not actually project buildDirectory into the apps.get response (or renames it to snake_case), the value is undefined, appDir collapses to "", prefix becomes "", and the CLI captures/reads the repo-root config instead of the app directory's. For every non-root (monorepo) app the feature would then silently no-op — the console never receives the app directory's deno.json, and no error is surfaced because the ?? "" masks the absence.
Why I am not treating this as a blocker: I found no evidence the contract is actually broken. The author controls both sides; normalizeBuildDirectory is explicitly written "as the console does," and a dedicated commit ("ignore a stored build directory that leaves the deploy root") only makes sense if apps.get returns that stored directory — so the field is clearly expected to be present. CI is green. The risk is a latent one that would bite only if the server projection drifts.
Suggestions: (a) make the absence explicit rather than silent — e.g. log/debug when build_config is present but buildDirectory is missing, so a future serializer change surfaces instead of silently deploying the wrong config; and (b) add a test that exercises a non-root appDir end to end (that the correct ${prefix}deno.json key is produced, ideally pinned to the exact response field name the server returns). The current tests/deno_json_files.test.ts covers normalizeBuildDirectory in isolation but nothing wires apps.get → appDir → captured key.
Related, lower-confidence: normalizeBuildDirectory maps any .. segment to root, so a stored "a/../b" becomes "" rather than "b". The comment asserts the console does the same ("any ..-escape to root"), which would make this consistent; I cannot confirm console behavior from this repo. Exotic (stored directories are normally clean like apps/web), so low risk — a targeted test would settle it if the console source is reachable.
Minor nit (not a bug)
The --config branch (publish.ts:139-140) has no try/catch around Deno.readTextFile(context.config), unlike the else branch which gracefully ignores Deno.errors.NotFound. In practice context.config was already read successfully by discoverConfig earlier, so this throws only in a narrow TOCTOU window (file deleted/permission-changed mid-run), yielding a raw stack trace instead of a graceful error. A one-line catch for symmetry would be nice but is not required.
Recommendation: No blocking issues. Posting as a comment given the (unconfirmed-but-plausible) silent-fallback robustness concern and the absence of end-to-end coverage for a non-root app directory; neither rises to a true blocker against a green CI with the author controlling both sides of the contract.
avocet-bot
left a comment
There was a problem hiding this comment.
Review (re-review) — #146 feat(deploy): send deno.json to the console on every deploy
Reviewed head SHA: ca25625e430542f9307872176e982821fe143a2f
Status: Not a draft; CI green (deno success, jsr success). No blocking issues. One residual non-blocking note carried over from the prior pass.
This head adds a single commit on top of the version reviewed earlier (e794f13): refactor(deploy): type the apps.get build directory through AppDetail. The rest of the PR is unchanged from that earlier full review, so this write-up focuses on the delta and restates the standing assessment.
Review skill evidence
Produced by invoking the Claude Code pr-review-toolkit:code-reviewer subagent (via the Task tool), scoped to the two-file delta (git diff e794f13..HEAD), plus my own trace. I re-ran the unit test locally (deno test --no-check -A tests/deno_json_files.test.ts → 1 passed) and confirmed the only type-check error is the unrelated missing generated wasm artifact (lib/rs_lib.js, a build step).
What this PR does (background for a new reader)
deploy-cli is the deno deploy command-line tool. It tars and hashes your project files, hands a manifest to the Deno Deploy console (the backend) via a tRPC call apps.initiateCliRevision, uploads the missing files, and the console builds/runs the app. A project's deno.json/deno.jsonc can carry a deploy section describing the build configuration.
The fix: previously the CLI uploaded files but never sent the contents of deno.json, so the console fell back to the build config stored at app-creation time — the deploy section only took effect once. This PR sends the app directory's deno.json/deno.jsonc on every deploy (matching how GitHub-sourced deploys already behave). To know which directory is "the app directory," publish() queries the tRPC apps.get endpoint for the app's stored build_config.buildDirectory, normalizes it (normalizeBuildDirectory, which mirrors the console's handling — dropping ./empty segments and mapping any ..-escape to the root), and captures/sends only that directory's config (with --config and excluded-from-upload fallbacks). Companion changes in deploy/create/flow.ts and deploy/create/mod.ts warn when a deno.json deploy section would override build settings passed to create.
The delta in this head
deploy/apps.ts— theAppDetailinterface (the CLI's type for anapps.getresponse row) is nowexported, and itsbuild_configmember gains a documented optional fieldbuildDirectory?: string.build_configremains typed{...} | null.deploy/publish.ts— imports theAppDetailtype and changes theapps.getresponse cast from an ad-hoc inline shape ({ build_config?: { buildDirectory?: string } }) toas AppDetail.
This is a direct, good-faith response to the type-contract observation from the prior review: the buildDirectory field the CLI relies on is now declared in the canonical shared AppDetail type (the same type apps.ts already uses for apps.get), rather than an inline cast private to publish.ts.
Delta assessment — clean, no regression
- The widened type is safe at the use site.
publish.tsreadsfullApp.build_config?.buildDirectory ?? "". The property type moved from the old inline optional property ({...} | undefined) toAppDetail's required-but-nullable ({...} | null). The optional-chain?.short-circuits on bothnullandundefined, producingstring | undefined, which?? ""closes to astring. It compiles and cannot throw. - No breakage for the other consumer.
apps.tsreadsdetail.build_config?.frameworkPreset ?? nulland theid/slug/created_at/updated_atfields; the newbuildDirectory?member is additive and optional, so nothing there changes.AppDetailhas exactly two consumers (apps.ts,publish.ts) and no stale inline reader was left behind. - Runtime behavior is byte-identical.
as AppDetailis a compile-time type assertion with no runtime validation — the same pattern used for every other tRPC response in this client (data as Revision,as Promise<AppDetail>,as string, etc.). Because a cast does not execute, the runtime is identical to the prior head.
Standing (non-blocking) note, carried over
Because as AppDetail still performs no runtime validation, the behavior I flagged last time persists: if the server's apps.get projection ever omitted build_config.buildDirectory (or renamed it), fullApp.build_config?.buildDirectory ?? "" would collapse appDir to the repo root, and a non-root (monorepo) app would silently send the root deno.json instead of its app directory's — the feature would no-op with no error surfaced. This commit reduces the risk by documenting the field as part of the shared response type (a clear statement of the expected contract) but does not eliminate it, and there is still no end-to-end test exercising a non-root appDir (the test covers normalizeBuildDirectory in isolation). I continue to treat this as non-blocking: I found no evidence the contract is actually broken, the author controls both client and console, normalizeBuildDirectory is explicitly written "as the console does," and CI is green. A debug log when build_config is present but buildDirectory is absent, plus a non-root-appDir end-to-end test, would close it out.
Everything else verified in the prior full review (the --config stand-in, the excluded-config disk fallback, the insideRoot guard, --json envelope cleanliness, stream tee behavior, and the create-flow warnings) is unchanged and still holds.
Recommendation: No blocking issues. The delta is a correct type-safety/documentation improvement that addresses prior feedback without changing runtime behavior. Posting as a comment given the one residual non-blocking item (unvalidated runtime cast → silent root-fallback, and the still-absent non-root end-to-end test).
Splits the selection into collectDenoJsonFiles and tests it for a nested app directory, an excluded config, --config, and a stored build directory leaving the deploy root.
…ct custom app directories in create * the excluded-config fallback resolves real paths and skips anything outside the deploy root * create detects the build config of a custom app directory, locally or via github.detectBuildConfigForRepo, in both the interactive and the flag-driven flow
avocet-bot
left a comment
There was a problem hiding this comment.
Review (re-review) — #146 feat(deploy): send deno.json to the console on every deploy
Reviewed head SHA: b47aae5918619ef24e1d97a63fc586a0af6d5375
Status: Not a draft; CI green (deno success, jsr success). No merge blockers. One should-fix robustness regression and a couple of minor notes, all non-blocking — details below.
This head adds two commits on top of the previously-reviewed ca25625:
test(deploy): cover which deno.json files a deploy sendsfix(deploy): resolve symlinks before reading an excluded config; detect custom app directories in create
This write-up covers that delta and restates the standing assessment; the rest of the PR is unchanged from the earlier full reviews.
Review skill evidence
Produced by invoking the Claude Code pr-review-toolkit:code-reviewer subagent (via the Task tool), scoped to the ca25625..b47aae5 delta, plus my own trace. I ran the full test file locally (deno test --no-check -A tests/deno_json_files.test.ts → 6 passed) and confirmed the only type-check error is the unrelated missing generated wasm artifact (lib/rs_lib.js, a build step).
What this PR does (background for a new reader)
deploy-cli is the deno deploy command-line tool. It tars and hashes your project files, hands a manifest to the Deno Deploy console (backend) via a tRPC call apps.initiateCliRevision, uploads the missing files, and the console builds/runs the app. A project's deno.json/deno.jsonc can carry a deploy section describing the build configuration.
The fix: previously the CLI never sent the contents of deno.json, so the console fell back to the build config stored at app-creation time and the deploy section took effect only once. This PR sends the app directory's deno.json/deno.jsonc on every deploy (matching GitHub-sourced deploys). The CLI learns the app directory by querying apps.get for the stored build_config.buildDirectory, normalizes it, and sends only that directory's config (with a --config stand-in and an excluded-from-upload disk fallback). Companion create changes warn when a deno.json deploy section would override build settings passed to create.
The delta in this head
deploy/publish.ts— refactor + security hardening. The deno.json collection is extracted into a new exported pure functioncollectDenoJsonFiles({rootPath, buildDirectory, uploaded, configPath});appBuildDirectorynow returns the raw stored build directory and the normalization happens insidecollectDenoJsonFiles. The important addition: the disk-read fallback for a config the upload excluded now resolves symlinks first — it callsDeno.realPathon both the deploy root and the candidate config path, then rejects the read whenrelative(root, path)equals"..", starts with"..<separator>", or is absolute. This closes a path-traversal/exfiltration hole: adeno.jsonsymlinked (directly, or via a symlinked app directory) to somewhere outside the deploy root can no longer be read from disk and transmitted to the console.deploy/create/flow.ts+mod.ts— custom-directory detection. A new exportedcustomDirectoryBuildConfig(trpcClient, rootPath, repo, path)detects the build config for an app directory that is not a detected workspace member. For a local source it callsdetectBuildConfig(...).catch(() => null); for a GitHub source it queries a new tRPC endpointgithub.detectBuildConfigForRepo. Both the interactivecreateFlow(custom directory selection) and the non-interactivecreatewarning path now use it, so thedeno.json-precedence warning also fires for custom GitHub app directories (previously a known gap).tests/deno_json_files.test.ts— expanded 14 → 145 lines. New end-to-end coverage forcollectDenoJsonFiles: non-root app directory selection, the excluded-config disk read, the--configstand-in, lexical root-escape rejection (../private,..\private), and symlink-escape rejection (both a symlinked directory and a symlinkeddeno.jsonpointing at a sibling private dir → returns{}, the secret is never read). This resolves the "no end-to-end test for a non-root app directory" gap I raised previously.
Delta assessment
Symlink containment guard — correct and well-tested. Deno.realPath(join(rootPath, appDir, name)) canonicalizes every path component, so it protects both a symlinked app directory and a symlinked config file; the subsequent relative(realPath(rootPath), path) escape check then rejects anything resolving outside the root. Because normalizeBuildDirectory already strips any .. from appDir, no lexical trick can reach the guard, and the ..${SEPARATOR} / exact-".." / isAbsolute triplet matches relative()'s platform-native output on both POSIX and Windows. The two symlink tests exercise both escape shapes and pass.
Refactor is behavior-preserving. The capture loop now collects every deno.json/.jsonc by basename into uploadedDenoJsonFiles, and collectDenoJsonFiles filters to the ${prefix}${name} keys; keys stay /-separated and consistent with the manifest, the --config override (delete .jsonc, set deno.json) and the "skip if already uploaded" guard match the prior logic, and moving normalizeBuildDirectory into collectDenoJsonFiles preserves the final normalization. No key/prefix mismatch.
Non-blocking finding (should-fix): asymmetric, uncaught GitHub detection query makes a warning-only path fatal
Background / intended behavior. customDirectoryBuildConfig exists only to decide whether to print the advisory DENO_JSON_PRECEDENCE_WARNING during create. Its declared contract is Promise<DetectedBuildConfig | null>, and every caller treats null as "nothing detected, don't warn." Detection here is best-effort — it must never gate whether create succeeds.
The problem (deploy/create/flow.ts:60-69). The local branch degrades failures to null via .catch(() => null), but the GitHub branch lets the github.detectBuildConfigForRepo tRPC query throw:
if (repo !== undefined) {
return await trpcClient.query("github.detectBuildConfigForRepo", { owner, repo, path }) as DetectedBuildConfig | null; // no .catch
}
return await detectBuildConfig(new FrameworkFileSystemReader(resolve(rootPath, path))).catch(() => null);Why this is a regression. At ca25625, a GitHub-source custom app directory (member === undefined) evaluated to undefined with no network call on both the non-interactive path (deploy/create/mod.ts:258-272) and the interactive path (deploy/create/flow.ts:256-265) — it could not fail. This head introduces a network call on those paths. github.detectBuildConfigForRepo is a brand-new endpoint; if it is unavailable during server rollout, 404s, or transiently 5xx's, the throw unwinds to handleCliError (main.ts) and aborts the command non-zero.
Practical impact. deno deploy create --source github --owner O --repo R --app-directory some/custom/dir … (where that directory is not a detected workspace member) previously succeeded (no warning, create proceeds); now a transient failure of the detection query aborts it. The blast radius is narrow (only create, only GitHub source, only a custom directory, only on endpoint error) and the failure is loud and recoverable rather than silent — which is why I am not treating it as a merge blocker — but a best-effort warning computation should not be able to fail the command.
Suggested fix. Mirror the local branch: append .catch(() => null) to the GitHub query (or wrap both branches). Note this is intentionally different from github.detectWorkspaceForRepo (mod.ts:227, flow.ts:636), which is essential to the flow and correctly allowed to throw — here null is the right failure mode.
Minor notes (not blocking)
- TOCTOU between
realPathandreadTextFile(publish.ts). The fallback catchesNotFoundonDeno.realPath(→ skip), then callsDeno.readTextFile(path)with no try/catch. Only a file deleted in the microsecond window between the two calls would throw uncaught; non-NotFounderrors (permission,IsADirectory) were equally uncaught atca25625, so there is no regression. Negligible. --configbranch has no containment check and no try/catch (collectDenoJsonFiles). This is byte-for-byte the prior behavior, and the path is user-explicit (the user chose--config somefile), so reading it and sending it as the app directory's stand-in is intended. Acceptable.- Standing runtime-cast note.
apps.getis still consumed via the compile-timeas AppDetailcast with a?? ""fallback (now insideappBuildDirectory), so if the server projection ever omittedbuild_config.buildDirectorythe app directory would silently resolve to root. Unchanged fromca25625, no evidence it is broken, now additionally backed by the new end-to-end tests for non-root directories.
Recommendation: No merge blockers. The security hardening (symlink containment) and the new end-to-end tests are solid improvements, and the publish.ts refactor is behavior-preserving. Posting as a comment: the one real item is the asymmetric uncaught github.detectBuildConfigForRepo query in customDirectoryBuildConfig (flow.ts:60-65), which turns a best-effort create warning into a potential command failure — a narrow, loud, one-line-fixable robustness regression worth addressing but not gating merge.
The GitHub lookup could exit the CLI on a failed request, for what is only a warning; custom GitHub app directories are no longer probed.
avocet-bot
left a comment
There was a problem hiding this comment.
Review (re-review — APPROVE) — #146 feat(deploy): send deno.json to the console on every deploy
Reviewed head SHA: c9eac00e9d8c69ecb7eec86e7b1eb9f38bad8fa8
Status: Not a draft; CI green (deno success, jsr success). Approving — every actionable finding from the prior review rounds is resolved and I see no blocking issues.
This head adds two commits on top of the previously-reviewed b47aae5:
fix(create): don't let the custom-directory deno.json check fail createfix(create): detect a custom app directory with the root's package manager
Both land in deploy/create/flow.ts/mod.ts and directly address the one should-fix item from my last review.
Review skill evidence
Produced by invoking the Claude Code pr-review-toolkit:code-reviewer subagent (via the Task tool), scoped to the b47aae5..c9eac00 delta, plus my own trace. I ran the full test file locally (deno test --no-check -A tests/deno_json_files.test.ts → 6 passed), confirmed detectPackageManager is exported by jsr:@deno/framework-detect@0.3.3 with the signature the new code relies on, and confirmed the only type-check error is the unrelated missing generated wasm artifact (lib/rs_lib.js, a build step).
What this PR does (background for a new reader)
deploy-cli is the deno deploy command-line tool. It tars and hashes your project files, hands a manifest to the Deno Deploy console (backend) via a tRPC call apps.initiateCliRevision, uploads the missing files, and the console builds/runs the app. A project's deno.json/deno.jsonc can carry a deploy section describing the build configuration.
The fix: the CLI never used to send the contents of deno.json, so the console fell back to the build config stored at app-creation time and the deploy section took effect only once. This PR sends the app directory's deno.json/deno.jsonc on every deploy (matching GitHub-sourced deploys). The CLI learns the app directory by querying apps.get for the stored build_config.buildDirectory, normalizes it, and sends only that directory's config — with a --config stand-in, an excluded-from-upload disk fallback, and a symlink-containment guard that prevents reading a config resolving outside the deploy root. Companion create changes print an advisory warning when a deno.json deploy section would override build settings passed to create.
The delta in this head
Both commits rewrite the customDirectoryBuildConfig helper, which detects the build config for an app directory that is not a detected workspace member. Its result feeds only advisory behavior: the DENO_JSON_PRECEDENCE_WARNING and, in the interactive flow, the "use detected build config?" prompt.
- GitHub branch no longer makes a fallible request. Previously it queried a new tRPC endpoint (
github.detectBuildConfigForRepo) with no error handling, so a transient or rollout-timing failure of that endpoint could abortcreate— the regression I flagged last round, since this is a warning-only computation that should never gate command success. It now simply returnsnull(the doc comment states the reasoning explicitly: not worth a request whose failure exits the CLI, for what is only a warning). The only effect is that a custom GitHub app directory no longer gets the advisory warning; the console still applies thedeno.jsondeploysection on deploy regardless, so there is no functional loss. - Local branch is now fully defensive and more accurate. The entire detection is wrapped in a single
try { … } catch { return null }(previously only thedetectBuildConfigcall had a.catch). It also now determines the package manager from the root lockfile viadetectPackageManager(new FrameworkFileSystemReader(rootPath))and passes it intodetectBuildConfig(new FrameworkFileSystemReader(resolve(rootPath, path)), packageManager)— matching howdetectWorkspaceresolves a nested app's package manager from the root lockfile. This is a strict improvement over the old branch, which passed no package manager. - Signature cleanup. The now-unneeded
trpcClientparameter is removed and both callers (flow.tscreateFlow,mod.tsnon-interactive) updated.
Delta assessment — clean
- Fully defensive: the only statement outside the
tryisif (repo !== undefined) return null, which cannot throw; every awaited call is inside thetry, so nothing can throw out ofcustomDirectoryBuildConfig. The prior-round regression is resolved. - Type-correct:
detectPackageManagerreturns"deno" | "npm" | "yarn" | "pnpm" | null, exactlydetectBuildConfig's optionalmaybePackageManager?union; passingnull(no root lockfile) reproduces the old no-package-manager behavior, and a non-null value only sharpens detection. - No dangling references: both callers use the new 3-arg signature, there are no remaining references to
github.detectBuildConfigForRepoanywhere, and the removedtrpcClientargument leaves no orphaned imports (createTrpcClient/TRPCClientremain used elsewhere in both files;detectPackageManager/resolve/FrameworkFileSystemReaderare all still used). - Tests: the suite (including the end-to-end
collectDenoJsonFilescases and the two symlink-escape tests added earlier) is unaffected and still passes (6/6).
Standing note (non-blocking, unchanged)
apps.get is consumed via a compile-time as AppDetail cast with a ?? "" fallback in appBuildDirectory, so if the server's apps.get projection ever omitted build_config.buildDirectory the app directory would silently resolve to the repo root. This is inherent to how this client casts every tRPC response (no runtime validation anywhere), there is no evidence the contract is broken, the field is now declared in the shared AppDetail type, and the behavior is additionally covered by the new non-root end-to-end tests. I note it only for completeness; it is not actionable and not a blocker.
Recommendation: approve. Across this PR's reviewed heads the feature has been verified end to end, hardened against symlink-based path traversal, given comprehensive tests, and every actionable finding I raised — most recently the uncaught custom-directory GitHub detection that could abort create — has been cleanly addressed. CI is green and there are no outstanding blocking or actionable non-blocking issues.
deno deployuploaded the files but never passed the deno.jsoncontents to
apps.initiateCliRevision, so the console fell back to theapp's stored config and the
deploysection only took effect once,when
deno deploy createcaptured it.deno deploylooks up the app's build directory (apps.get) andsends that directory's deno.json/deno.jsonc as
denoJsonFiles; theconsole applies its
deploysection as it does for GitHubdeployments. It is read from disk when
deploy.include/excludeleaves it out of the upload, and a config selected with
--configtakes its place.
createwarns when deno.json will override the build config given toit (
--do-not-use-detected-build-config,--build-timeout, a customapp directory, or a custom config in the interactive flow), except
under
--json, and no longer prompts for a timeout deno.json decides.Behavior change: CLI apps whose deno.json has a
deploysection nowbuild with it on every deploy instead of the stored config. The app's
build memory limit,
cronsDisabledand executor are kept(denoland/deployng#3773, in prod).
createalso detects the build config of a local custom app directory(with the root's package manager), so it offers the detected config and
warns as for workspace members. The excluded-config
fallback resolves symlinks and never reads outside the deploy root.
Not covered:
create's warning does not inspect a--configfile or acustom GitHub app directory (that lookup's failure would exit the CLI),
and
a deno.json found only above the deploy root (a workspace config) is not
sent, as GitHub deployments also read only the app directory's.