Skip to content

Support independent monorepo deployments with source roots - #1412

Merged
evanphx merged 7 commits into
mainfrom
evan/config-profiles
Nov 3, 2026
Merged

evanphx merged 7 commits into
mainfrom
evan/config-profiles

Conversation

@evanphx

@evanphx evanphx commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Why

A monorepo can contain several independently deployed apps that share source code but need different builds, scaling settings, and deployment targets. Each app can now keep an ordinary .miren directory alongside its service while building from a shared repository root.

Approach and tradeoffs

Set [build] source_root = "../.." in services/api/.miren/app.toml, then deploy with miren deploy -d services/api or from that service's directory. The path resolves relative to the directory containing .miren, not to .miren itself. Dockerfile and include paths use the source root; deployment targets stay service-local. Omitting source_root preserves the existing build context.

Absolute source roots are rejected by appconfig validation. Relative parent traversal remains supported and can reach outside the repository; repository confinement is not part of this change. The docs explain that detection runs at the source root and that a root Dockerfile.miren takes precedence over detection and an explicit web image unless build.dockerfile selects a Dockerfile explicitly.

The client projects the selected app config into the upload-root .miren/app.toml, including its hash and metadata in delta manifests, without changing repository files. The server receives its usual layout, so no server or RPC configuration-selection changes are needed. This replaces the earlier profile design: there is no --profile, MIREN_PROFILE, or custom early flag parsing.

Elixir builds prefer a declared Mix release matching the app name with hyphens converted to underscores, allowing my-shop to select my_shop while retaining the first-release fallback. This applies to literal release lists, not arbitrary evaluated Elixir expressions.

Verification

  • Before rebasing, hack/it ./appconfig ./cli/... ./pkg/tarx ./pkg/deploygating ./pkg/stackbuild ./servers/build -p 1 passed; 2,109 tests reported with seven Docker-dependent skips because Docker was unavailable.
  • Reproduced the review-reported ENOENT when extracting a projected config without a root .miren directory. The writer now emits the missing directory header; full and filtered upload tests round-trip through the real TarFS extractor, and CLI-to-server analysis runs without a root .miren fixture.
  • After the fix and rebase onto current origin/main, hack/it ./appconfig ./pkg/tarx ./cli/commands -p 1 passed: 1,399 tests.
  • Final source-root resolution/upload and CLI-to-server analysis checks passed (14 tests), including rejection of absolute source roots. Elixir release-name regressions passed, including a hyphenated app selecting a later underscored release.
  • Final focused source-root, target-isolation, gating, and CLI-to-server analysis regressions passed. Coverage includes distinct service configurations, full and delta uploads, cached configs, ignored configs, and unchanged repository files.
  • make lint: docs lint and both golangci-lint runs passed with zero issues, including a rerun before the latest review-fix commits.
  • The documentation build passed during the review follow-ups; final docs lint passed. Earlier, go generate ./... produced no further changes. git diff --check passed after the follow-ups.

Thread: https://ampcode.com/threads/T-90d45f7e-d058-40ca-b3c1-1c78ff4670b1

miren-code-agent[bot]

This comment was marked as outdated.

@evanphx
evanphx marked this pull request as ready for review October 26, 2026 23:45
@evanphx
evanphx requested a review from a team as a code owner October 26, 2026 23:45
miren-code-agent[bot]

This comment was marked as outdated.

miren-code-agent[bot]

This comment was marked as outdated.

@phinze phinze self-assigned this Oct 27, 2026

@phinze phinze left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

OK! After that helpful walkthrough yesterday and sitting with it for a bit, I think 🤖 and I found a narrower change that solves for the use case a little more cleanly.

Today, we have one concept of a "root" as expressed by AppCentric.Dir aka --dir.

We can split this concept into two different roots:

  • Config root: where .miren/ lives
  • Source root: the directory the CLI uploads and the server builds from (and what [build] dockerfile resolves against)

As implemented here, profiles essentially allow independent selection of config file, while keeping both roots bound to --dir.

I think if we decouple the source root instead, we get the same benefits with a simpler change and an easier mental model for users.

If we add [build] source_root to app.toml, that lets .miren dirs sit naturally in subdirs while referencing parents.

So e.g. imagine a Go repo with bigapp at the root and sideservice sharing its module:

repo/
├── go.mod
├── .miren/app.toml              # name = "bigapp"
├── cmd/
│   ├── bigapp/main.go
│   └── sideservice/
│       ├── main.go
│       └── .miren/app.toml      # name = "sideservice"
└── pkg/shared/...
# cmd/sideservice/.miren/app.toml
name = "sideservice"

[build]
source_root = "../.."

The source root becomes the repo root, so the build sees go.mod and pkg/shared. The Go stack already builds ./cmd/<app name> when it finds a cmd/ dir, so it picks ./cmd/sideservice with no further config. miren deploy -d cmd/sideservice works from the root, cd cmd/sideservice && miren deploy works too, and bigapp is unchanged.

Rust works the same way today: with a virtual workspace at the root, the stack falls back to the app name to pick the binary out of target/release/. Elixir umbrellas need a small tweak, since the stack currently builds whichever entry comes first in releases:. Choosing the release that matches the app name would bring it in line. So the app name already does the selecting in two of three stacks.

A layout the stack can't infer just names its Dockerfile, which resolves against source_root:

# services/sideservice/.miren/app.toml
name = "sideservice"

[build]
source_root = "../.."
dockerfile = "services/sideservice/Dockerfile"

The neat part about this is it can reuse your tarx.FileOverride impl, we just use it to project .miren/app.toml into the root of the tarball.

So one new config in app.toml, no global anything, multiple deployment roots supported. Whatchya think?

@evanphx
evanphx force-pushed the evan/config-profiles branch from 1cb95a4 to 4ea087c Compare October 28, 2026 22:09
@evanphx evanphx changed the title Add client-side config profiles for monorepo deployments Support independent monorepo deployments with source roots Oct 28, 2026
miren-code-agent[bot]

This comment was marked as outdated.

evanphx commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up to #1412 (review):

Reproduced the missing-parent failure by extending TestSourceRootUploads to unpack with the actual tarx.TarFS extractor: the no-default layout failed with ENOENT. Prepared a client-side writer fix that emits a synthetic directory header when an override parent is absent, while retaining existing directory metadata when present. No server changes. The upload tests now verify extracted contents for full and filtered archives, and TestAnalyzeSourceRoot no longer creates a root .miren directory.

Elixir app-name matching is intentional as part of the accepted source-root proposal. Updated the guide and quick reference to state the exact-match preference, first-entry fallback, lack of hyphen/underscore normalization, and impact on existing apps matching a later release.

Verification: hack/it ./appconfig ./pkg/tarx ./cli/commands -p 1 passed (1,395 tests); make lint passed with zero issues. These follow-ups are local, uncommitted and unpushed, pending author approval to publish. The PR head has not changed.

— e + 🤖

@evanphx
evanphx force-pushed the evan/config-profiles branch from 4ea087c to 888052e Compare October 28, 2026 22:51

evanphx commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

The locally verified follow-ups are now published after rebasing onto main: 01d23eaf adds missing parent-directory headers and extraction regressions; 888052e8 clarifies Elixir matching and existing-app behavior. The post-rebase affected-package suite passed (1,399 tests), and make lint passed again before committing. CI is running on the new head.

— e + 🤖

miren-code-agent[bot]

This comment was marked as outdated.

@phinze phinze left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This turned out really nicely! The split between config root and source root reads cleanly through runDeploy. Approving. Three comments inline: the one I would most like to see some movement on (here or in a fast follow) is how far source_root can reach, since right now a repo can point the build context anywhere on the deployer's disk. The other two are a docs paragraph on auto-detection and an Elixir naming nit.

--p+🤖

Comment thread appconfig/source.go Outdated
if ac == nil || ac.Build == nil || ac.Build.SourceRoot == "" {
return configDir
}
if filepath.IsAbs(ac.Build.SourceRoot) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Nothing bounds source_root right now. Absolute paths are accepted, so is any number of ../, and gating only checks that the result is a directory. So a cloned repo can set source_root = "/home/you" (or enough ../ to get there), and miren deploy will upload that directory as the build context for a Dockerfile the repo controls. Before this PR the build context was always the directory you pointed -d at.

To be fair about scale: deploying an untrusted repo already means running its code on your cluster. What is new is reach into the deployer's local disk, which seems worth some guard.

How far to go is your call. The cheap end needs no new decisions: reject absolute paths, and print the resolved source root during deploy whenever it differs from the config root, so a surprising ../../../.. is visible. The thorough end is keeping the resolved root inside the enclosing repo. pkg/git already shells out to both git and jj, so finding the root is small, but it brings questions (no VCS, submodules, missing binaries) that might be better as a follow-up issue than as part of this PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I checked this against appconfig/source.go:14-16 and you're right. SourceDir returns filepath.Clean(ac.Build.SourceRoot) for absolute paths and filepath.Join(configDir, ...) for relative ones. Nothing compares the result to configDir, any repo root, or the -d target. I missed this in both earlier reviews, so thanks for catching it. I agree the new risk is reach into the deployer's local disk, not code execution on the cluster. The cheap end you describe looks right for this PR: reject absolute paths, and print the resolved root whenever it differs from the config dir. A repo-containment check can be a follow-up. It's the author's call, but if nothing else lands I'd want at least the visibility line, so a ../../.. can't pass silently.

Comment thread docs/docs/deployment.md

In a monorepo, give each independently deployed service its own `.miren` directory and app name. Services can share source code while choosing their own Dockerfile, commands, environment variables, scaling settings, and deployment targets. Unlike defining several services in one `app.toml`, these are separate Miren apps: deploying or rolling back one does not deploy or roll back the others.

Set `build.source_root` when an app needs source outside its own directory. Miren resolves it relative to the **config root**, the directory containing `.miren`, and uses the resulting **source root** as the upload root and build context. Dockerfile and top-level `include` paths are relative to the source root. Without `source_root`, the config root and source root are the same, as before.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Worth a paragraph here on what happens when an app sets source_root but no dockerfile. Detection now runs against the source root, which cuts both ways.

The good side: stack inference already selects by app name in a few stacks. The Go stack builds ./cmd/<name>, a Rust virtual workspace picks the <name> binary, and Elixir now prefers the <name> release. So many services need nothing beyond source_root, and every example here sets dockerfile, which hides that.

The trap: resolveBuildSource (servers/build/build_saga.go:1019) prefers a Dockerfile.miren at the upload root over stack detection and even over an explicit [services.web] image. If the root app has a Dockerfile.miren, a service pointing source_root at the root quietly builds the root app. Saying so, and that setting dockerfile avoids it, would save someone a confusing deploy.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I can confirm the precedence. In resolveBuildSource, the osStat(path, "Dockerfile.miren") check comes before both webImageSource(ac) and stackbuild.DetectStack. So a root Dockerfile.miren beats a service's explicit [services.web] image as well as detection. Last round I flagged the auto-detect-from-source-root behaviour in general, but I hadn't noticed it overrides an explicit image too. That makes a docs paragraph more worthwhile. I agree it should cover both sides: name-based inference often works with only source_root, and a root Dockerfile.miren wins unless dockerfile is set.

Comment thread pkg/stackbuild/elixir.go Outdated
case "]":
depth--
default:
if depth == 1 && string(token[1]) == opts.Name {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Non-blocking, but the docs added in this PR use hyphenated app names (my-api, my-worker), while release keys are atoms like my_api. An umbrella monorepo following those docs will miss this match and quietly fall back to the first release. Normalizing - to _ in opts.Name before comparing seems cheap, and I cannot think of a case where someone would want my-api not to match my_api. If you would rather keep it exact, the docs already say so, so this is a judgment call.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Last round I treated exact matching as settled, because the author said it was intentional and documented it. You've found a gap I didn't weigh: the same docs use hyphenated names like my-api, and Mix release keys can't contain hyphens, so those examples never get the exact match. Normalizing - to _ on the app-name side before comparing seems low-risk to me. I agree it's a judgment call, and I wouldn't block on it.

miren-code-agent[bot]

This comment was marked as outdated.

Keep app configuration and deployment targets in each service directory while resolving build.source_root relative to that directory. Upload the selected config at the canonical path in full and delta build contexts without changing the repository or server APIs.

Cover source resolution, upload projection, nested config and alias selection, local deployment targets, gating, and CLI-to-server analysis.
Select a matching declared Mix release for independently deployed umbrella apps. Preserve the first-release fallback and ignore nested application names and keys outside the release list.
Describe service-local .miren directories with shared source roots, separate build and scaling strategies, deployment targets, and CI jobs. Add build.source_root to the app configuration schema.
Synthesize directory headers for override parents absent from the source root so the unchanged server extractor can unpack service-local app configs. Round-trip full and filtered uploads through TarFS and exercise analysis without a root .miren directory.
Validate source_root as a relative config-root path while retaining parent traversal for monorepos. Reject invalid paths in appconfig rather than adding per-command checks.
Map app-name hyphens to underscores when selecting a declared Mix release. Document source-root detection, root Dockerfile precedence, and the remaining reach of relative source paths without adding deploy output.
@evanphx
evanphx force-pushed the evan/config-profiles branch from 4f62021 to dd217b5 Compare November 3, 2026 23:00

@miren-code-agent miren-code-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🍪 biscuit: ✅ ready to merge — auto-review, non-blocking

This is ready for human review. Since my last review the branch has been rebased onto main: the head moved from 4f62021 to dd217b5, the seven commit messages are the same, and pr_status now reports the PR as mergeable, so the rebase point is settled. I found no new defects in the rebased code.

The server-version gap I raised still stands, and my thread at docs/docs/deployment.md:149 stays open:

  • UploadFiles (appconfig/source.go:19-24) still copies the service's app.toml into the upload byte for byte, so build.source_root reaches the server.
  • loadAppConfig (servers/build/build.go:1238) parses that file with appconfig.Parse, which calls DisallowUnknownFields(). A server built before this PR will therefore reject the key.
  • Line 149 and the PR description still say the server needs no changes.

Either remove the key from the projected copy, or document that this feature needs a server that knows source_root. Either would close the thread.

I'm still not recommending approval, for the same reasons as last time:

  • The override path in makeTarWithFilter runs on every deploy.
  • The Elixir release preference changes which release existing umbrella apps build.
  • The server-compatibility question above still needs a decision.

phinze's threads and my old profile handoff thread are still for people to close.


🍪 full review note · reviewed at dd217b5

@evanphx
evanphx enabled auto-merge November 3, 2026 23:04
@evanphx
evanphx merged commit fb2e70f into main Nov 3, 2026
26 checks passed
@evanphx
evanphx deleted the evan/config-profiles branch November 3, 2026 23:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants