Skip to content

fix(cli): resolve deploy ${VAR} secrets from the project root for config/deploy.yml - #3142

Merged
bpamiri merged 1 commit into
developfrom
peter/issue-3084-deploy-secrets-path
Jun 12, 2026
Merged

fix(cli): resolve deploy ${VAR} secrets from the project root for config/deploy.yml#3142
bpamiri merged 1 commit into
developfrom
peter/issue-3084-deploy-secrets-path

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

wheels deploy's ${VAR} interpolation never read the .kamal/secrets file that wheels deploy init scaffolds. ConfigLoader.load() built its lazy SecretResolver with projectRoot: getDirectoryFromPath(arguments.path), so the standard config/deploy.yml layout resolved secrets from config/.kamal/secrets — a file nothing creates — while DeploySecretsCli.print() and registry login both resolve from the project root. The process-env fallback masked the gap.

Fix

New ConfigLoader.$projectRootFor(path) helper: when the deploy.yml sits inside a directory named config, the secrets root is the parent of that directory (the project root); any other layout keeps the YAML's own directory, preserving the existing "secrets alongside deploy.yml" behavior the spec suite relies on. Destination overlays (.kamal/secrets.<dest>) follow the same root, since SecretResolver derives both paths from the one projectRoot.

This covers all ten loader.load() call sites (DeployMainCli, App/Build/Registry/Lock/Prune/Proxy/Server/Accessory CLIs, SshPoolFactory) without plumbing changes, and explicit --configPath values outside a config/ dir behave exactly as before.

Acceptance (from #3084)

  • Root-level .kamal/secrets feeds ${VAR} interpolation for the standard config/deploy.yml layout — spec resolves ${VAR} from project-root .kamal/secrets when deploy.yml lives in config/ (#3084)
  • Interpolation and the DeploySecretsCli / registry-login resolution agree on one root (both now use the project root; $projectRootFor unit spec pins the rule)

Evidence (Lucee 7 docker harness, /wheels/cli/tests)

Run pass fail error
Red (new specs, implementation stashed) 926 3 3
Green (implementation restored) 929 1 2

Red-run failures were exactly the three new specs (Expected [acme/fromProjectRoot] but received [acme/], ditto for the destination overlay, and no function with name [$projectRootFor]). The residual 1 fail / 2 errors are identical in both runs and are container-environment artifacts unrelated to this change: SshClientSpec/SshPoolSpec global exceptions (docker: command not found inside the harness container — no docker-in-docker) and ServerCommandsSpec :: reload endpoint responds (needs a live CLI-started server).

Core suite sanity (/wheels/core/tests?db=sqlite): 4445 pass / 12 fail / 0 error — only the tolerated internal.testClientSpec artifacts.

Fixes #3084.

🤖 Generated with Claude Code

…fig/deploy.yml

ConfigLoader built its lazy SecretResolver with the YAML file's own
directory as projectRoot, so the standard config/deploy.yml layout
looked for config/.kamal/secrets — a file nothing creates — and the
.kamal/secrets leg of ${VAR} interpolation silently never fired
(masked by the process-env fallback).

$projectRootFor() now steps up to the parent of a directory named
config, so interpolation reads the project-root .kamal/secrets that
wheels deploy init scaffolds and that DeploySecretsCli and registry
login already use. A deploy.yml outside a config/ directory keeps
resolving .kamal/secrets alongside itself (existing spec unchanged).

Fixes #3084

Signed-off-by: Peter Amiri <peter@alurium.com>
@github-actions github-actions Bot added the docs label Jun 12, 2026

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer

TL;DR: This PR fixes #3084wheels deploy's ${VAR} interpolation built its lazy SecretResolver from the deploy.yml's own directory, so the standard config/deploy.yml layout looked for secrets at config/.kamal/secrets (a file nothing creates) while DeploySecretsCli and registry login resolve from the project root. The new $projectRootFor() helper steps up to the parent of a config/ directory and leaves every other layout alone. I verified the logic line by line, the cross-component agreement claim, the TDD evidence, and conventions — no blocking findings. Verdict: approve.

Verified during review

  • Root-agreement claim is accurate. DeploySecretsCli.cfc:83 (projectRoot: arguments.opts.projectRoot ?: expandPath("./")) and DeployRegistryCli.cfc:81 (new ...SecretResolver() → same expandPath("./") default in SecretResolver.cfc:35) both resolve from the project root; with Module.cfc:2181 defaulting configPath to expandPath("config/deploy.yml"), $projectRootFor() now lands interpolation on that same root. All three consumers agree, as the PR body claims.
  • Destination overlay follows automatically. SecretResolver.$secretPath() (SecretResolver.cfc:65-72) derives both .kamal/secrets and .kamal/secrets.<dest> from the single projectRoot, so the overlay spec is real coverage, not incidental.
  • Backward compatibility preserved. The pre-existing spec (ConfigLoaderSpec.cfc:58-70, deploy.yml with .kamal/ alongside, no config/ dir) still exercises the "alongside" layout, and $projectRootFor() returns dir unchanged for it.
  • Cross-engine invariant 8 handled correctly. left(dir, len(dir) - 1) at ConfigLoader.cfc:82 is guarded by len(dir) > 1, so Left(str, 0) can never fire on Lucee 7. The root-path edge (/config/deploy.yml/) is pinned by the unit spec.
  • Helper visibility matches house style. public + $ prefix mirrors the existing $overlayPathFor / $interpolate / $resolveVar siblings in the same CFC, and per cli/CLAUDE.md public is warranted here because the spec calls it directly; MCP tool discovery only reads Module.cfc, so no surface leak.
  • TDD evidence is genuine. Red run failed on exactly the three new specs; the residual 1 fail / 2 errors are the known docker-in-docker / live-server harness artifacts, identical in both runs.
  • Docs already match the new behavior. The deploy guides and wheels deploy init scaffold the project-root .kamal/secrets; this change aligns code with what users were already told. Changelog fragment changelog.d/deploy-secrets-project-root.fixed.md uses the correct fixed type.
  • Commit (fix(cli): resolve deploy ${VAR} secrets from the project root for config/deploy.yml) conforms to commitlint (valid type, scope, subject length, explains the why).

Non-blocking observations

  • listLast(trimmed, "/\") == "config" at ConfigLoader.cfc:83 compares case-insensitively (CFML ==), so /srv/app/Config/deploy.yml also steps up. That's the right call for Windows paths and harmless elsewhere — just noting it's deliberate-looking and fine.
  • With a relative --configPath config/deploy.yml, getDirectoryFromPath("config") yields no parent and the len(parent) guard at ConfigLoader.cfc:85 falls back to the pre-PR behavior — no regression, since the default path is absolute and relative paths behaved this way before.

Nice, tightly-scoped fix with a pinned unit rule and honest evidence. Approving.

@bpamiri
bpamiri merged commit 10b9e3e into develop Jun 12, 2026
7 checks passed
@bpamiri
bpamiri deleted the peter/issue-3084-deploy-secrets-path branch June 12, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

deploy: ${VAR} interpolation resolves .kamal/secrets relative to deploy.yml's directory (config/.kamal/secrets), not the project root

1 participant