fix(docker): close the five host-path follow-ups #22 left open #48

Merged
julian merged 5 commits from fix/issue-47-host-path-followups into main 2026-08-21 23:20:35 +02:00
Owner

Closes #47 — the five non-blocking findings from @thisilike's approving review of #22, taken in one pass. They are independent of each other; the diff is grouped so each can be read on its own.

1. applyPin's read guard is narrowed to Deno.errors.NotFound

pin_apply.ts. The catch swallowed every read failure. In production hostFs.read throws synchronously — the arrow evaluates hostPath(file) before a promise exists — so the drive-under-mounted-root refusal was never actually swallowed; what the narrowing buys is the permission-denied and directory cases. That is a behaviour change: a pin over a stack with one unreadable override file used to skip that file and proceed, and now fails the run, with the compensator rolling the edit back. Only a genuinely absent, untouched file is skipped. Two tests: a read rejecting asynchronously with a non-NotFound error must still surface (it fails if the narrowing is reverted), and an untouched, genuinely absent file is still skipped.

2. envFiles join the one definition — and one container answers for them

The env files went to the host compose CLI as --env-file raw, unresolved and unchecked, while composeEligibility stat'd everything else projectArgs puts on the command line — and compose exits non-zero on an --env-file it cannot open exactly as it does on a missing -f. Now every argument is checked, a missing env file gets its own verdict, and a path in both lists is a compose file first, matching the order the resolve loop already used.

Which files those are is the answering container's to say, and after four review rounds it is the simplest rule on the table — the one @thisilike proposed: the first non-one-off container in the daemon's created-descending order answers, verbatim — label or no label.

  • No fall-through to an older labelled sibling. Absence is an answer: an operator who moves values into the project's .env and re-ups leaves untouched services holding the abandoned label, and resurrecting that file re-interpolates every service from it (--env-file suppresses compose's own .env discovery), then strands the stack on the API updater the moment they delete it. The cost is owned in the docstring, in both directions: a newest container that lost the label by accident reads identically to a deliberate drop, nothing in the labels distinguishes them — and "none" is not a neutral answer either: it hands interpolation to the .env discovery the label was suppressing, a different value set is a different config hash, and the next update quietly recreates the affected services on .env values. Resurrecting an abandoned file is still the worse mistake.
  • No union (resurrects abandoned files and widens the pin path's argv) and no createdAt/name tie-break (Created * 1000 is whole seconds; a name is not recency). The daemon's own list order already is recency, at its own resolution.
  • One-offs are skipped by com.docker.compose.oneoff: compose run --env-file scratch.env creates the project's newest container, and listStacks passes all=true, so an exited debugging one-off would otherwise set the env files of every later update and pin. Round 5 extends the skip to every project label, not just the env files: answeringContainer picks the container once and configFiles, workingDir and envFiles are all read off it — the stack page and the pin's validate fallback go through the same chooser and the shared splitLabel instead of their own containers[0] splits — so an invocation can never pair a one-off's throwaway -f debug.yaml with the live deploy's --env-file, an argv no deploy ever produced (and one whose deleted throwaway file would strand the stack even less clearably than the env case: compose's orphan detection skips one-offs, so --remove-orphans will not clear the holder). A project of nothing but one-offs is not addressable — correctly, there is no deploy to address. The one-off fixture diverges in all three labels, so a read that takes even one off the wrong container fails the test.
  • A stale label can still speak for the project — an orphan that is the newest non-one-off, or a live service compose had no reason to recreate — and no OpsDeck action can clear one: plain up -d never rewrites a standing container's labels, and the compose invocation that would is on the path eligibility disables. So the missing-env-file verdict now names the container the label lives on and the host-side recovery (docker compose up -d --force-recreate, or --remove-orphans for an orphaned holder), threaded as ComposeProject.envFilesFrom. The stack page reads the same answer through composeProject — the mod.ts seam takes the already-split set by type, so a second label read does not compile.

3. effectiveHostRoot takes the union, not string

hostpath.ts typed { host: { strategy: string | null } } while HostStrategy is exported from @opsdeck/sdk — which types ctx.host.strategy as HostStrategy | null itself, so the widening was purely local. As it was, renaming a union member left === "direct" compiling and silently always-false, and everything now rides on that one equality. Same trade as deleting dir from CommitLines: make it structural. composeEligibility forwards the same ctx, so it narrows with it; both test helpers pass the union.

4. The README sentence gets its missing clause

OPSDECK_HOST_ROOT=/ fixes the paths, and reads genuinely work after it. Anything through ctx.host still does not: the probe proves every candidate — chroot, two nsenter variants, and direct — by running an sh -c test through it, so without a POSIX shell on PATH no candidate passes and the strategy stays unset.

Concretely, on a Windows checkout the stack pages work and a pin dies at h.step("validate") with host command execution unavailable — honestly reported, edit rolled back by the compensator. A stack update never dies over it: composeAvailable returns the reason and the run degrades to the engine-API recreate, logging compose unavailable … — using the engine API. README.md is now where the Windows setup is documented, so it says which features degrade and which fail, rather than lumping the updater — which succeeds — in with the failures.

5. The graceful failure mode is written down

composeEligibility's docstring now records what the reasons are for: a non-null answer routes to the engine-API updater, so #22's working-directory stat narrows eligibility without failing a run. It also owns the trade honestly: eligibility supplies a stable answer, but no label rule can guarantee the set is always the deploy in force — a stale label makes the downgrade permanent, which is exactly why the verdict names the container and the recovery.


deno task check, deno lint and deno fmt --check are green. deno task test: 494 passed | 1 failed — the failure is external_modules_test.ts "a module that loads after the app is built still gets its API routes", pre-existing on this Windows checkout (environment, not the diff). Every new env-file test verified load-bearing by reverting its fix in isolation: reverting answeringContainer to containers[0] fails 3 (the diverged one-off fixture, the only-one-offs stack, and the page seam), restoring the fall-through fails 1, restoring the union fails 3, dropping the verdict suffix fails 1.

Refs #20, #22.

🤖 Generated with Claude Code

Closes #47 — the five non-blocking findings from @thisilike's approving review of #22, taken in one pass. They are independent of each other; the diff is grouped so each can be read on its own. ### 1. `applyPin`'s read guard is narrowed to `Deno.errors.NotFound` `pin_apply.ts`. The catch swallowed **every** read failure. In production `hostFs.read` throws *synchronously* — the arrow evaluates `hostPath(file)` before a promise exists — so the drive-under-mounted-root refusal was never actually swallowed; what the narrowing buys is the permission-denied and directory cases. That is a **behaviour change**: a pin over a stack with one unreadable override file used to skip that file and proceed, and now fails the run, with the compensator rolling the edit back. Only a genuinely absent, untouched file is skipped. Two tests: a read rejecting *asynchronously* with a non-`NotFound` error must still surface (it fails if the narrowing is reverted), and an untouched, genuinely absent file is still skipped. ### 2. `envFiles` join the one definition — and one container answers for them The env files went to the host compose CLI as `--env-file` raw, unresolved and unchecked, while `composeEligibility` stat'd everything else `projectArgs` puts on the command line — and compose exits non-zero on an `--env-file` it cannot open exactly as it does on a missing `-f`. Now every argument is checked, a missing env file gets its own verdict, and a path in both lists is a compose file first, matching the order the resolve loop already used. Which files those are is the answering container's to say, and after four review rounds it is the simplest rule on the table — the one @thisilike proposed: **the first non-one-off container in the daemon's created-descending order answers, verbatim — label or no label.** - **No fall-through** to an older labelled sibling. Absence is an answer: an operator who moves values into the project's `.env` and re-ups leaves untouched services holding the abandoned label, and resurrecting that file re-interpolates *every* service from it (`--env-file` suppresses compose's own `.env` discovery), then strands the stack on the API updater the moment they delete it. The cost is owned in the docstring, in both directions: a newest container that lost the label by accident reads identically to a deliberate drop, nothing in the labels distinguishes them — and "none" is not a neutral answer either: it hands interpolation to the `.env` discovery the label was suppressing, a different value set is a different config hash, and the next update quietly recreates the affected services on `.env` values. Resurrecting an abandoned file is still the worse mistake. - **No union** (resurrects abandoned files and widens the pin path's argv) and **no `createdAt`/name tie-break** (`Created * 1000` is whole seconds; a name is not recency). The daemon's own list order already is recency, at its own resolution. - **One-offs are skipped** by `com.docker.compose.oneoff`: `compose run --env-file scratch.env` creates the project's newest container, and `listStacks` passes `all=true`, so an exited debugging one-off would otherwise set the env files of every later update and pin. Round 5 extends the skip to **every** project label, not just the env files: `answeringContainer` picks the container once and `configFiles`, `workingDir` and `envFiles` are all read off it — the stack page and the pin's validate fallback go through the same chooser and the shared `splitLabel` instead of their own `containers[0]` splits — so an invocation can never pair a one-off's throwaway `-f debug.yaml` with the live deploy's `--env-file`, an argv no deploy ever produced (and one whose deleted throwaway file would strand the stack even less clearably than the env case: compose's orphan detection skips one-offs, so `--remove-orphans` will not clear the holder). A project of nothing but one-offs is not addressable — correctly, there is no deploy to address. The one-off fixture diverges in all three labels, so a read that takes even one off the wrong container fails the test. - **A stale label can still speak for the project** — an orphan that is the newest non-one-off, or a live service compose had no reason to recreate — and no OpsDeck action can clear one: plain `up -d` never rewrites a standing container's labels, and the compose invocation that would is on the path eligibility disables. So the missing-env-file verdict now names the container the label lives on and the host-side recovery (`docker compose up -d --force-recreate`, or `--remove-orphans` for an orphaned holder), threaded as `ComposeProject.envFilesFrom`. The stack page reads the same answer through `composeProject` — the `mod.ts` seam takes the already-split set by type, so a second label read does not compile. ### 3. `effectiveHostRoot` takes the union, not `string` `hostpath.ts` typed `{ host: { strategy: string | null } }` while `HostStrategy` is exported from `@opsdeck/sdk` — which types `ctx.host.strategy` as `HostStrategy | null` itself, so the widening was purely local. As it was, renaming a union member left `=== "direct"` compiling and silently always-false, and everything now rides on that one equality. Same trade as deleting `dir` from `CommitLines`: make it structural. `composeEligibility` forwards the same ctx, so it narrows with it; both test helpers pass the union. ### 4. The README sentence gets its missing clause `OPSDECK_HOST_ROOT=/` fixes the *paths*, and reads genuinely work after it. Anything through `ctx.host` still does not: the probe proves every candidate — `chroot`, two `nsenter` variants, **and `direct`** — by running an `sh -c` test through it, so without a POSIX shell on `PATH` no candidate passes and the strategy stays unset. Concretely, on a Windows checkout the stack pages work and a pin dies at `h.step("validate")` with `host command execution unavailable` — honestly reported, edit rolled back by the compensator. A stack update never dies over it: `composeAvailable` returns the reason and the run degrades to the engine-API recreate, logging `compose unavailable … — using the engine API`. README.md is now where the Windows setup is documented, so it says which features degrade and which fail, rather than lumping the updater — which succeeds — in with the failures. ### 5. The graceful failure mode is written down `composeEligibility`'s docstring now records what the reasons are *for*: a non-null answer routes to the engine-API updater, so #22's working-directory stat narrows eligibility without failing a run. It also owns the trade honestly: eligibility supplies a *stable* answer, but no label rule can guarantee the set is always the deploy in force — a stale label makes the downgrade permanent, which is exactly why the verdict names the container and the recovery. --- `deno task check`, `deno lint` and `deno fmt --check` are green. `deno task test`: **494 passed | 1 failed** — the failure is `external_modules_test.ts` "a module that loads after the app is built still gets its API routes", pre-existing on this Windows checkout (environment, not the diff). Every new env-file test verified load-bearing by reverting its fix in isolation: reverting `answeringContainer` to `containers[0]` fails 3 (the diverged one-off fixture, the only-one-offs stack, and the page seam), restoring the fall-through fails 1, restoring the union fails 3, dropping the verdict suffix fails 1. Refs #20, #22. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(docker): close the five host-path follow-ups #22 left open
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m21s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m11s
d1f7d7c001
Closes #47. Each is independent; none blocked the #22 merge.

- applyPin's read guard is narrowed to Deno.errors.NotFound. It used to
  swallow every read failure, so the drive-under-mounted-root refusal
  reached the operator only because hostFs.read throws synchronously,
  before the catch could attach — an async refactor with no intent behind
  it silently turned the named refusal into no-image-line on the write
  path the guard exists to protect. Only a genuinely absent file is "not
  here" now; the refusal survives the shape of the call.
- composeEligibility resolves and stats the env files. They went to the
  host CLI as --env-file raw and unchecked while the same label got a
  normalising function (wantedEnvFiles) for the stack page, and compose
  exits non-zero on an --env-file it cannot open exactly as it does on a
  missing -f. Everything projectArgs puts on the command line is checked
  now, with the missing env file getting its own verdict.
- effectiveHostRoot takes HostStrategy | null rather than string | null.
  Everything rides on one string equality, and against `string` renaming
  a union member leaves the comparison compiling and always-false. Same
  trade as deleting `dir` from CommitLines: make it structural.
- README says what OPSDECK_HOST_ROOT=/ does NOT fix. The probe proves
  every candidate — direct included — with an `sh -c`, so with no POSIX
  shell on PATH the strategy stays unset and compose validate, the
  compose updater and a pin's git commit fail with "host command
  execution unavailable" while the stack pages work.
- composeEligibility's docstring records that every verdict is graceful:
  a non-null reason routes to the engine-API updater, so the working
  directory stat #22 added narrows eligibility without failing a run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thisilike left a comment

Re-reviewed the whole diff at d1f7d7c in a detached worktree, reproducing the claims rather than reading them. Most of this holds up. Two of the five items do not, and one behaviour change is undersold.

Reproduced and confirmed

  • deno task check, deno lint and deno fmt --check clean. deno task test488 passed | 0 failed | 1 ignored, exactly as stated. (Two external_modules_test.ts cases fail on my machine with gpg failed to sign the data — my git config, not this branch; green with signing off.)
  • Item 1. The narrowing test at pin_apply_test.ts:201 is load-bearing: revert to if (restore.has(file)) throw e; and it fails. The synchronous-throw analysis is right — hostFs.read's arrow evaluates hostPath(file) before returning a promise, so .catch never attaches.
  • Item 2's premise is correct, verified against docker compose 5.5.0 rather than assumed: compose absolutises --env-file into the label (so there is no relative-label regression to worry about), records multiple files comma-separated (matching composeProject's split), does not record an auto-discovered .env (so the blast radius is only stacks deployed with an explicit --env-file), and does refuse a missing one — couldn't find env file: …. Both new eligibility tests are load-bearing.
  • Item 3. ctx.host.strategy was already HostStrategy | null in sdk/mod.ts:93, so the widening really was purely local — and the narrowing bites: === "dircet" now fails with TS2367 … types 'HostStrategy | null' and '"dircet"' have no overlap, which it did not before. hostpath.ts is backend-only, so no module-builder bare-import exposure.
  • Item 5's docstring is accurate. All three composeEligibility call sites in actions.ts (206, 275, 557) route a non-null reason to the engine-API path.
  • README's other claims check out: host command execution unavailable is HostUnavailableError's message and moduleHostExec.run throws it on a null strategy; the pin does die at h.step("validate") (neither pin action consults composeAvailable/composeEligibility); commitLines goes through ctx.host.run; file browsing is Deno.readTextFile(hostPath(p)) (mod.ts:608,644) and stacks/logs are dockerFetch, so "browsing still works" is right.

What does not hold

Details inline. Summary:

  1. Item 4's new README clause is wrong about the probe in the exact configuration it documents, and it restates a premise the codebase already gets wrong twice.
  2. pin_apply_test.ts:227 is a duplicate of the existing test at :159 and passes with the fix reverted. Item 1 added one test, not two.
  3. Item 2 converts a loud failure into a silent capability downgrade, keyed on the one compose label that is not uniform across a project's containers. Demonstrated live below.
  4. composeEligibility re-spells effectiveHostRoot's ctx shape instead of sharing it — the drift this file argues against three times.

Also worth noting: the new docstring flags the workingDir stat as "a behaviour change" and says nothing about the env-file stat, which is the change this PR is actually making, and the larger of the two.

Re-reviewed the whole diff at `d1f7d7c` in a detached worktree, reproducing the claims rather than reading them. Most of this holds up. Two of the five items do not, and one behaviour change is undersold. ### Reproduced and confirmed - `deno task check`, `deno lint` and `deno fmt --check` clean. `deno task test` → **488 passed | 0 failed | 1 ignored**, exactly as stated. (Two `external_modules_test.ts` cases fail on my machine with `gpg failed to sign the data` — my git config, not this branch; green with signing off.) - **Item 1.** The narrowing test at `pin_apply_test.ts:201` is load-bearing: revert to `if (restore.has(file)) throw e;` and it fails. The synchronous-throw analysis is right — `hostFs.read`'s arrow evaluates `hostPath(file)` before returning a promise, so `.catch` never attaches. - **Item 2's premise is correct**, verified against docker compose 5.5.0 rather than assumed: compose absolutises `--env-file` into the label (so there is no relative-label regression to worry about), records multiple files comma-separated (matching `composeProject`'s split), does **not** record an auto-discovered `.env` (so the blast radius is only stacks deployed with an explicit `--env-file`), and does refuse a missing one — `couldn't find env file: …`. Both new eligibility tests are load-bearing. - **Item 3.** `ctx.host.strategy` was already `HostStrategy | null` in `sdk/mod.ts:93`, so the widening really was purely local — and the narrowing bites: `=== "dircet"` now fails with `TS2367 … types 'HostStrategy | null' and '"dircet"' have no overlap`, which it did not before. `hostpath.ts` is backend-only, so no module-builder bare-import exposure. - **Item 5's docstring is accurate.** All three `composeEligibility` call sites in `actions.ts` (206, 275, 557) route a non-null reason to the engine-API path. - README's other claims check out: `host command execution unavailable` is `HostUnavailableError`'s message and `moduleHostExec.run` throws it on a null strategy; the pin does die at `h.step("validate")` (neither pin action consults `composeAvailable`/`composeEligibility`); `commitLines` goes through `ctx.host.run`; file browsing is `Deno.readTextFile(hostPath(p))` (`mod.ts:608,644`) and stacks/logs are `dockerFetch`, so "browsing still works" is right. ### What does not hold Details inline. Summary: 1. **Item 4's new README clause is wrong about the probe in the exact configuration it documents**, and it restates a premise the codebase already gets wrong twice. 2. **`pin_apply_test.ts:227` is a duplicate of the existing test at `:159`** and passes with the fix reverted. Item 1 added one test, not two. 3. **Item 2 converts a loud failure into a silent capability downgrade**, keyed on the one compose label that is *not* uniform across a project's containers. Demonstrated live below. 4. `composeEligibility` re-spells `effectiveHostRoot`'s ctx shape instead of sharing it — the drift this file argues against three times. Also worth noting: the new docstring flags the *workingDir* stat as "a behaviour change" and says nothing about the *env-file* stat, which is the change this PR is actually making, and the larger of the two.
README.md Outdated
@ -55,1 +52,3 @@
them.
that cannot exist. That fixes the _paths_, and reads genuinely work after it;
anything that runs a command on the host still does not. Host exec proves each
candidate (`chroot`, two `nsenter` variants, then spawning directly) by running
Owner

Two problems, and this is the one item of the five whose entire content is precision.

The candidate list is wrong in the configuration this same sentence just prescribed. With OPSDECK_HOST_ROOT=/, hostexec.ts:181 gives:

const candidates: HostStrategy[] = this.hostRoot && this.hostRoot !== "/"
  ? ["chroot", "nsfile", "pidns", "direct"]
  : ["direct"];

chroot and both nsenter variants are never attempted. The conclusion (the strategy stays unset) survives; the reason given for it does not.

"not even the direct candidate passes" rests on an unsound premise. direct is not a POSIX construct — argv("direct", cmd, args) returns the command verbatim. The only POSIX thing in play is the probe's own choice of sh. Where sh.exe is resolvable (Git for Windows installed with the Unix tools on PATH, MSYS2, scoop), test ! -e /.dockerenv && test ! -e /run/.containerenv returns 0, the strategy becomes direct, effectiveHostRoot returns / — the same root the reader was just told to configure — and compose validation and the pin's git commit run against the Windows docker CLI. So "anything that runs a command on the host still does not" is conditional, not flat.

Worth fixing at the source rather than only here: hostpath.ts's joinHostPath docstring ("the host-exec probe cannot answer direct — every candidate strategy is a POSIX construct") and effectiveHostRoot's ("On Windows null is the ONLY reachable state") both already carry this premise, and both use it to justify a refusal message. This PR now has the README repeating it.

Two problems, and this is the one item of the five whose entire content is precision. **The candidate list is wrong in the configuration this same sentence just prescribed.** With `OPSDECK_HOST_ROOT=/`, `hostexec.ts:181` gives: ```ts const candidates: HostStrategy[] = this.hostRoot && this.hostRoot !== "/" ? ["chroot", "nsfile", "pidns", "direct"] : ["direct"]; ``` `chroot` and both `nsenter` variants are never attempted. The conclusion (the strategy stays unset) survives; the reason given for it does not. **"not even the direct candidate passes" rests on an unsound premise.** `direct` is not a POSIX construct — `argv("direct", cmd, args)` returns the command verbatim. The only POSIX thing in play is the probe's own choice of `sh`. Where `sh.exe` is resolvable (Git for Windows installed with the Unix tools on PATH, MSYS2, scoop), `test ! -e /.dockerenv && test ! -e /run/.containerenv` returns 0, the strategy becomes `direct`, `effectiveHostRoot` returns `/` — the same root the reader was just told to configure — and compose validation and the pin's git commit run against the Windows `docker` CLI. So "anything that runs a command on the host still does not" is conditional, not flat. Worth fixing at the source rather than only here: `hostpath.ts`'s `joinHostPath` docstring ("the host-exec probe cannot answer `direct` — every candidate strategy is a POSIX construct") and `effectiveHostRoot`'s ("On Windows null is the ONLY reachable state") both already carry this premise, and both use it to justify a refusal message. This PR now has the README repeating it.
@ -85,2 +94,3 @@
p: ComposeProject,
ctx: { host: { strategy: string | null }; paths: { hostRoot: string } },
ctx: {
host: { strategy: HostStrategy | null };
Owner

This re-spells effectiveHostRoot's parameter shape instead of sharing it: two structurally identical inline types that now have to stay in sync by hand. That is the drift the docstrings in this file and in hostpath.ts argue against three times over — "a string parameter lets a fourth call site do it again", "copies drifting apart is how the last regression happened".

Export the shape next to effectiveHostRoot and have both signatures plus both test helpers (compose_update_test.ts:115, hostpath_test.ts:110) take it. Then item 3's fix is structural in the way it claims to be, instead of being the same widening closed in two places.

This re-spells `effectiveHostRoot`'s parameter shape instead of sharing it: two structurally identical inline types that now have to stay in sync by hand. That is the drift the docstrings in this file and in `hostpath.ts` argue against three times over — "a string parameter lets a fourth call site do it again", "copies drifting apart is how the last regression happened". Export the shape next to `effectiveHostRoot` and have both signatures plus both test helpers (`compose_update_test.ts:115`, `hostpath_test.ts:110`) take it. Then item 3's fix is structural in the way it claims to be, instead of being the same widening closed in two places.
@ -91,3 +102,1 @@
// validated AND stat'd with the config files: compose is addressed with
// --project-directory, and a directory that is not there fails the real
// update just as surely as a missing -f file.
// reads the exact path that guard would write. Everything projectArgs
Owner

True of this function, not of the codebase — and the PR body states it unqualified.

Both pin actions in actions.ts call composeConfigArgsprojectArgs inside h.step("validate") (:394, :508) and never consult composeEligibility or composeAvailable. So one stale env-file label now gets two different answers: stack-update silently degrades to the engine API with a named verdict, stack-pin fails at validate with compose's raw stderr wrapped in compose-rejected and rolls the edit back.

Narrow the comment, or wire the guard into validate so the same condition reads the same way on both paths.

Separately, the docstring above singles out the workingDir stat as "a behaviour change" and is silent on the env-file stat — which is the change this PR is making, and the one that turns a loud error into a silent downgrade.

True of this function, not of the codebase — and the PR body states it unqualified. Both pin actions in `actions.ts` call `composeConfigArgs` → `projectArgs` inside `h.step("validate")` (`:394`, `:508`) and never consult `composeEligibility` or `composeAvailable`. So one stale env-file label now gets two different answers: `stack-update` silently degrades to the engine API with a named verdict, `stack-pin` fails at validate with compose's raw stderr wrapped in `compose-rejected` and rolls the edit back. Narrow the comment, or wire the guard into validate so the same condition reads the same way on both paths. Separately, the docstring above singles out the workingDir stat as "a behaviour change" and is silent on the env-file stat — which is the change this PR is making, and the one that turns a loud error into a silent downgrade.
@ -94,3 +110,3 @@
const hostRoot = effectiveHostRoot(ctx);
const onHost = new Map<string, string>();
for (const path of [p.workingDir, ...p.configFiles]) {
for (const path of [p.workingDir, ...p.configFiles, ...p.envFiles]) {
Owner

The env-file label is the one compose project label that is not uniform across a project's containers, and composeProject reads it from stack.containers[0] — which is raw /containers/json order (engine.ts:163-175 groups by project and never sorts within one; created-descending in practice). So whichever container was recreated last decides the whole project's envFiles.

Live repro on this host, compose 5.5.0, two services, b recreated with --env-file sub/rel.env:

pr48ord-b-1   envfile=[…/envprobe/sub/rel.env]   <- index 0
pr48ord-a-1   envfile=[]

and driven through the real composeProject + composeEligibility with that file since deleted:

b recreated with --env-file (newest first)
  envFiles=["/stacks/gone.env"]   verdict=env file not found on host: /stacks/gone.env
b recreated without it (newest first)
  envFiles=[]                     verdict=null

Same project, same compose files, the verdict flips on recreation order. Not hypothetical either — the exchange project on this machine has exactly 1 of 31 containers carrying the label.

Before this change that flip produced compose's own couldn't find env file: …: loud, and it names the fix. After it, it produces a silent switch to the updater this file's own header describes as recreating from the OLD image's inspect.Config and unable to pull from private registries — with the reason only in h.log, which non-admin observers do not get (expose: ["entities"]). That is a worse trade than "worse-but-working", and it is decided by a coin flip.

Either of these would make it hold up:

  • derive envFiles from something stable rather than containers[0] — the union across the project's containers, or reuse wantedEnvFiles, which already models the required/optional split this needs;
  • or drop a missing env file from the command line instead of abandoning the compose path, so a stale label costs an argument rather than the whole better updater.
The env-file label is the one compose project label that is **not uniform across a project's containers**, and `composeProject` reads it from `stack.containers[0]` — which is raw `/containers/json` order (`engine.ts:163-175` groups by project and never sorts within one; created-descending in practice). So whichever container was recreated last decides the whole project's `envFiles`. Live repro on this host, compose 5.5.0, two services, `b` recreated with `--env-file sub/rel.env`: ``` pr48ord-b-1 envfile=[…/envprobe/sub/rel.env] <- index 0 pr48ord-a-1 envfile=[] ``` and driven through the real `composeProject` + `composeEligibility` with that file since deleted: ``` b recreated with --env-file (newest first) envFiles=["/stacks/gone.env"] verdict=env file not found on host: /stacks/gone.env b recreated without it (newest first) envFiles=[] verdict=null ``` Same project, same compose files, the verdict flips on recreation order. Not hypothetical either — the `exchange` project on this machine has exactly 1 of 31 containers carrying the label. Before this change that flip produced compose's own `couldn't find env file: …`: loud, and it names the fix. After it, it produces a silent switch to the updater this file's own header describes as recreating from the OLD image's `inspect.Config` and unable to pull from private registries — with the reason only in `h.log`, which non-admin observers do not get (`expose: ["entities"]`). That is a worse trade than "worse-but-working", and it is decided by a coin flip. Either of these would make it hold up: - derive `envFiles` from something stable rather than `containers[0]` — the union across the project's containers, or reuse `wantedEnvFiles`, which already models the required/optional split this needs; - or drop a missing env file from the command line instead of abandoning the compose path, so a stale label costs an argument rather than the whole better updater.
@ -200,0 +224,4 @@
}
});
Deno.test("a file that is genuinely absent is still skipped", async () => {
Owner

This is the test at :159 with the filenames swapped.

:159  dir({"base.yml": BASE})  pin(["/nope.yml", "/base.yml"]) -> ok, patches ["/base.yml"]
:227  dir({"over.yml": BASE})  pin(["/base.yml", "/over.yml"]) -> ok, patches ["/over.yml"]

Same rule, same assertion shape, missing file first in both. I reverted the narrowing to if (restore.has(file)) throw e; and ran the file:

a read that fails for any reason other than absence is not 'not here' ... FAILED
a file that is genuinely absent is still skipped ... ok

So the PR body's "Two tests" is one new test plus a rename of one that already passes on main untouched. Either drop it, or make it earn the line — assert that the absent file is NotFound specifically and that a sibling failure in the same walk (EACCES, EISDIR) throws instead of being skipped. That is the half of the rule the narrowing actually changed and nothing covers it.

This is the test at `:159` with the filenames swapped. ``` :159 dir({"base.yml": BASE}) pin(["/nope.yml", "/base.yml"]) -> ok, patches ["/base.yml"] :227 dir({"over.yml": BASE}) pin(["/base.yml", "/over.yml"]) -> ok, patches ["/over.yml"] ``` Same rule, same assertion shape, missing file first in both. I reverted the narrowing to `if (restore.has(file)) throw e;` and ran the file: ``` a read that fails for any reason other than absence is not 'not here' ... FAILED a file that is genuinely absent is still skipped ... ok ``` So the PR body's "Two tests" is one new test plus a rename of one that already passes on `main` untouched. Either drop it, or make it earn the line — assert that the absent file is `NotFound` *specifically* and that a sibling failure in the same walk (`EACCES`, `EISDIR`) throws instead of being skipped. That is the half of the rule the narrowing actually changed and nothing covers it.
fix(docker): the env-file answer stops depending on recreation order
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m12s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m0s
cd42477d6e
Addresses @thisilike's requested changes on #48. Four of the five, plus the
one the review named as undersold.

The env-file label is the one compose project label that is not uniform
across a project's containers — compose records it only on containers made
by an invocation that passed --env-file — and composeProject read it off
containers[0], which is raw /containers/json order. So the project's env
files, and after this PR its eligibility verdict, were a function of which
container had been recreated last. projectEnvFiles takes the union over the
project's containers, walked by name, preserving compose's own comma order
within a label. It is a wider answer than containers[0] on purpose: a
project whose newest container lost the label now gets addressed with the
--env-file its deploy used. The docstring records that widening and the
behaviour change the eligibility docstring had left implicit — an env file
that is gone used to reach compose and die on its own "couldn't find env
file", and now degrades to the engine-API updater like every other missing
path.

The claim that "everything projectArgs puts on the command line is checked"
is narrowed to this guard's own callers. The pin actions build the same
argv inside their validate step and deliberately do not consult it: a pin
has already edited the operator's files and has no worse-but-working path
to fall back to, so there a missing path must fail the run.

The README clause about host exec was wrong in the configuration the same
sentence prescribes: with OPSDECK_HOST_ROOT=/ the probe tries only its
`direct` candidate, and `direct` is not a POSIX construct — the command is
spawned verbatim, and the only POSIX thing in play is the probe's own
`sh -c` test. Where an sh is resolvable the strategy becomes direct and
host commands run against the Windows docker CLI. The same premise was
being used to justify a refusal message in joinHostPath's and
effectiveHostRoot's docstrings, so it is corrected at the source too.

HostRootCtx is exported next to effectiveHostRoot; composeEligibility and
both test helpers take it instead of re-spelling the shape.

The duplicate pin test is replaced by one that covers the half the
narrowing actually changed: a genuinely absent file is skipped and a
directory-where-a-file-should-be in the same walk throws. It fails with the
narrowing reverted; so do both new eligibility tests with the union
reverted.

deno task check, deno lint, deno fmt --check clean; 490 passed, 1 ignored.

Refs #47, #22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Pushed cd42477. All five inline findings taken; the reproductions all hold, and two of them changed what the fix is rather than how it is worded.

1. composeProject no longer reads env files off containers[0] (comment 852)

The coin flip is the finding, and it is a worse one than the PR body's item 2 was a fix. projectEnvFiles takes the union over the project's containers, walked by NAME so the order compose is handed does not depend on the daemon's, and preserving compose's own comma order within a label — for duplicate keys the last --env-file wins, so that order is semantic.

Two tests, both load-bearing (they fail with envFiles: splitLabel(first.labels?.[…]) restored): the same two containers in either order give the same answer, and two containers with overlapping label lists union without duplicates.

Worth being explicit that this is a wider answer than containers[0], in both directions. Your exchange case — 1 of 31 containers carrying the label — now has that label govern the project, so compose is addressed with the --env-file the deploy actually used instead of silently interpolating without it. That is the point. The cost is the other direction of the same fact: the project's eligibility now depends on that file still existing, where a stack whose newest container had lost the label would previously have sailed past the check. Stable and wide beats narrow by accident of ordering, and the docstring says so rather than leaving it to be discovered.

I did not take the second option (drop a missing env file from the argv). Once the derivation is stable a missing env file is a real missing env file, and dropping it is not free: --env-file suppresses compose's default .env discovery, so dropping the last one silently swaps in a different set of values rather than none.

2. The env-file stat is written up as the behaviour change it is (comment 853)

You were right that the docstring flagged the workingDir stat and stayed silent on the one this PR is making. It now names both, says which is bigger, and says what it costs: a stack with a gone env file used to reach compose and die on its own couldn't find env file: … — loud, and it named the fix — and now degrades to the engine-API updater like every other missing path. It also records that the reason lands in h.log only. That trade is only defensible because of item 1, and the docstring makes the dependency explicit.

3. "Everything projectArgs puts on the command line is checked" is narrowed (comment 853)

Narrowed to this guard's own callers, with the divergence named: the pin actions build the same argv through composeConfigArgs inside h.step("validate") and do not consult the guard. I did not wire it in, and the comment now says why rather than leaving it looking like an oversight — a pin has already edited the operator's compose files and has no worse-but-working path to fall back to, so a missing path there must fail the run, and compose's own stderr names it better than a verdict would. The compensator rolls the edit back. Two paths, two answers, on purpose.

4. The README clause, and the premise underneath it (comment 850)

Both halves conceded. With OPSDECK_HOST_ROOT=/ the candidate list is ["direct"] — no chroot, no nsenter — and direct is not a POSIX construct; argv("direct", …) returns the command verbatim, and the only POSIX thing in play is the probe's own sh -c test. The README now says that where an sh is resolvable the test passes, the strategy becomes direct, and host commands run against the Windows docker CLI; where there is none, the strategy stays unset and everything shelling out fails with host command execution unavailable.

Fixed at the source too, since you pointed out this PR was repeating a premise the code already had wrong twice: joinHostPath's docstring now justifies the refusal by the pairing that is actually reachable (default /host/root + a null strategy) and notes that taking the documented fix makes the refusal unreachable — "/" trims to an empty base; effectiveHostRoot's no longer claims null is the only state Windows can reach; the same sentence in hostpath_test.ts:98 goes with them.

5. HostRootCtx (comment 854)

Exported next to effectiveHostRoot. composeEligibility and both test helpers (compose_update_test.ts, hostpath_test.ts) take it. Item 3's fix is structural now instead of being the same widening closed in two places.

6. The duplicate test (comment 851)

Dropped, and replaced with the half the narrowing actually changed, in one walk through the real hostFs: /gone.yml is genuinely absent and is skipped, and /isdir.yml is a directory where a compose file should be. The IsADirectory must reach the caller; swallowing it reports the whole pin as no-image-line. Reverting to if (restore.has(file)) throw e; fails it — both files get skipped and the walk returns that wrong verdict. The absence half stays covered by the existing test above it, which is where it always was.


deno task check, deno lint and deno fmt --check clean. deno task test490 passed | 0 failed | 1 ignored. Every new test was checked against the reverted fix rather than assumed load-bearing.

🤖 Generated with Claude Code

Pushed `cd42477`. All five inline findings taken; the reproductions all hold, and two of them changed what the fix is rather than how it is worded. ### 1. `composeProject` no longer reads env files off `containers[0]` (comment 852) The coin flip is the finding, and it is a worse one than the PR body's item 2 was a fix. `projectEnvFiles` takes the **union over the project's containers**, walked by NAME so the order compose is handed does not depend on the daemon's, and preserving compose's own comma order within a label — for duplicate keys the last `--env-file` wins, so that order is semantic. Two tests, both load-bearing (they fail with `envFiles: splitLabel(first.labels?.[…])` restored): the same two containers in either order give the same answer, and two containers with overlapping label lists union without duplicates. Worth being explicit that this is a **wider** answer than `containers[0]`, in both directions. Your `exchange` case — 1 of 31 containers carrying the label — now has that label govern the project, so compose is addressed with the `--env-file` the deploy actually used instead of silently interpolating without it. That is the point. The cost is the other direction of the same fact: the project's eligibility now depends on that file still existing, where a stack whose newest container had lost the label would previously have sailed past the check. Stable and wide beats narrow by accident of ordering, and the docstring says so rather than leaving it to be discovered. I did not take the second option (drop a missing env file from the argv). Once the derivation is stable a missing env file is a real missing env file, and dropping it is not free: `--env-file` suppresses compose's default `.env` discovery, so dropping the last one silently swaps in a different set of values rather than none. ### 2. The env-file stat is written up as the behaviour change it is (comment 853) You were right that the docstring flagged the workingDir stat and stayed silent on the one this PR is making. It now names both, says which is bigger, and says what it costs: a stack with a gone env file used to reach compose and die on its own `couldn't find env file: …` — loud, and it named the fix — and now degrades to the engine-API updater like every other missing path. It also records that the reason lands in `h.log` only. That trade is only defensible because of item 1, and the docstring makes the dependency explicit. ### 3. "Everything projectArgs puts on the command line is checked" is narrowed (comment 853) Narrowed to this guard's own callers, with the divergence named: the pin actions build the same argv through `composeConfigArgs` inside `h.step("validate")` and do not consult the guard. I did not wire it in, and the comment now says why rather than leaving it looking like an oversight — a pin has already edited the operator's compose files and has no worse-but-working path to fall back to, so a missing path there must fail the run, and compose's own stderr names it better than a verdict would. The compensator rolls the edit back. Two paths, two answers, on purpose. ### 4. The README clause, and the premise underneath it (comment 850) Both halves conceded. With `OPSDECK_HOST_ROOT=/` the candidate list is `["direct"]` — no `chroot`, no `nsenter` — and `direct` is not a POSIX construct; `argv("direct", …)` returns the command verbatim, and the only POSIX thing in play is the probe's own `sh -c` test. The README now says that where an `sh` is resolvable the test passes, the strategy becomes `direct`, and host commands run against the Windows `docker` CLI; where there is none, the strategy stays unset and everything shelling out fails with `host command execution unavailable`. Fixed at the source too, since you pointed out this PR was repeating a premise the code already had wrong twice: `joinHostPath`'s docstring now justifies the refusal by the pairing that is actually reachable (default `/host/root` + a null strategy) and notes that taking the documented fix makes the refusal unreachable — `"/"` trims to an empty base; `effectiveHostRoot`'s no longer claims null is the only state Windows can reach; the same sentence in `hostpath_test.ts:98` goes with them. ### 5. `HostRootCtx` (comment 854) Exported next to `effectiveHostRoot`. `composeEligibility` and both test helpers (`compose_update_test.ts`, `hostpath_test.ts`) take it. Item 3's fix is structural now instead of being the same widening closed in two places. ### 6. The duplicate test (comment 851) Dropped, and replaced with the half the narrowing actually changed, in one walk through the real `hostFs`: `/gone.yml` is genuinely absent and is skipped, and `/isdir.yml` is a directory where a compose file should be. The `IsADirectory` must reach the caller; swallowing it reports the whole pin as `no-image-line`. Reverting to `if (restore.has(file)) throw e;` fails it — both files get skipped and the walk returns that wrong verdict. The absence half stays covered by the existing test above it, which is where it always was. --- `deno task check`, `deno lint` and `deno fmt --check` clean. `deno task test` → **490 passed | 0 failed | 1 ignored**. Every new test was checked against the reverted fix rather than assumed load-bearing. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
thisilike left a comment

Re-reviewed cd42477 in a detached worktree, checking every claim against the code rather than the description. deno task check, deno lint and deno fmt --check are clean here. deno task test gives 488 passed / 2 failed / 1 ignored — both failures are external_modules_test.ts dying on gpg: signing failed: Timeout in my environment, not this branch; 490 total matches your count.

I reverted each of the four new tests' fixes one at a time: envFiles: splitLabel(first.labels?.[…]) fails both composeProject env tests, dropping ...p.envFiles from the resolve loop fails both eligibility tests, and if (restore.has(file)) throw e; fails both pin_apply tests. All four are load-bearing, as claimed.

What holds: the README's host-exec claims are all true against hostexec.tshostRoot === "/" really does reduce candidates to ["direct"] (#runProbe), the proof really is sh -c 'test ! -e /.dockerenv …' (#tryStrategy), and host command execution unavailable really is what the three shelling-out features hit (actions.ts:398/512, compose_update.ts:387, commit.ts), while stacks/files/logs go through dockerFetch and work either way. joinHostPath's new closing sentence checks out: hostRoot.replace(/\/+$/, "") turns "/" into "", so the drive refusal is genuinely unreachable once the documented fix is applied. HostStrategy is a real four-member union in packages/sdk/mod.ts:72 and the readonly-to-mutable structural pass compiles. The Deno.errors.NotFound narrowing is the right test for the only production PinFs, and the secrecy claim in the eligibility docstring is accurate — composeAction sets expose: ["entities"], so a verdict carrying an env-file path stays admin-only.

Item 1 is smaller than the write-up suggests, though: hostFs.read is still a non-async arrow calling hostPath(file) before Deno.readTextFile, so it still throws synchronously and the drive-path refusal was never actually swallowed in production. What the narrowing really buys is the permission-denied and IsADirectory cases — which is worth having, and is also a behaviour change nothing in the PR body names: a pin over a stack with one unreadable override file used to skip it and proceed, and now fails the run.

The env-file work is where I want changes. Item 1 of the follow-up comment fixed the derivation's stability and in doing so gave away its intent, and the same coin flip it removes is still live one function away.

1. A stale env file can override the current one, and container names decide which wins

projectEnvFiles unions across containers in localeCompare(name) order, and the docstring correctly states that for duplicate keys the last --env-file wins. So the merge precedence between two different env files is now decided by container name. Repro, run against this branch:

containers: media-api-1 (createdAt 2000, label new.env)
            media-zz-1  (createdAt 1000, label old.env)

envFiles: [ "/opt/stacks/media/new.env", "/opt/stacks/media/old.env" ]
argv:     --env-file /opt/stacks/media/new.env --env-file /opt/stacks/media/old.env
last wins -> /opt/stacks/media/old.env

That is the real shape, not a contrived one: the operator changes --env-file from old.env to new.env and runs compose up -d; compose recreates only the services whose config changed, so an untouched service keeps a container carrying the old label. Under containers[0] the newest container's label won, which was unstable but at least tracked the operator's most recent intent. Under the union, the values they deliberately moved away from come back and, if the stale container sorts later, override the new ones. Rename the services and the answer flips.

ContainerInfo already carries createdAt, which is the field this decision actually wants. Either sort by createdAt (newest last, so newest wins, and the union is at least ordered by recency), or take the newest container that has the label — stable and intent-preserving, and it keeps the exchange case you cite, since 1 of 31 containers carrying the label is still the only evidence there is.

While that is open: localeCompare is the wrong comparator for an order you have declared semantic. It is collation-dependent — ["media-DB-1","media-api-1"] sorts to ["media-api-1","media-DB-1"] under localeCompare and the other way under code-unit order. A precedence decision should not move with ICU data or locale.

2. The union widens argv on the path you documented as having no fallback

composeConfigArgs is projectArgs plus config -q, so both pin actions (actions.ts:397, actions.ts:511) now pass every unioned --env-file into h.step("validate"). A stack with one stale container whose label names a since-deleted env file previously validated fine when the newest container had no label; now compose exits non-zero on an --env-file it cannot open, the pin fails, and the compensator rolls back an edit that would have applied. The docstring's cost analysis covers only the graceful eligibility path — the ungraceful one takes the same widening and gets a hard failure out of it. Please either say so there or reconsider the union in favour of the newest-labelled-container rule, which does not widen anything.

3. The stack page still reads the label off containers[0]

mod.ts:633 (not in this diff):

const wanted = wantedEnvFiles(
  first?.labels?.["com.docker.compose.project.environment_file"],
  ...

wantedEnvFiles normalises the spelling of the label; it has no opinion about which container the label came from. So the exact coin flip your docstring describes still governs what the stack page lists — and now the page and the updater derive the set differently, so a stack can show one env file and be validated against two. The PR body's framing ("the same label got a dedicated normalising function in wantedEnvFiles for the stack page while staying raw for the invocation") reads as if the page side was already handled; it wasn't. Whichever rule item 1 lands on, composeProject should be the one place that answers this, with collectStackFiles taking the answer from it.

4. The docstring's cited cause cannot happen

compose records it only on containers created by an invocation that passed --env-file, so recreating one service without it (the API updater does exactly that) leaves the project split.

The API updater does not do that. update.ts:220 builds createBody = { ...inspect.Config, Image: image, HostConfig: hostConfig, … }, and inspect.Config.Labels rides along, so a recreate through the engine API preserves the compose labels — env-file label included. Splits are real (a partial compose up -d without --env-file, as in the repro above), so the conclusion survives; the named mechanism is wrong. Given the last round's finding was this PR repeating premises the code already had wrong, a load-bearing docstring asserting a behaviour of a sibling file it does not have seems worth fixing at the source rather than shipping.

5. Minor

  • compose_update_test.ts:40 — "the env-file label is per-container" states an asymmetry that does not hold as cleanly as written. config_files splits the same way: change the -f set, run compose up -d, and services whose config did not change keep containers carrying the old config_files label. Reading it off containers[0] is defensible because containers[0] is the newest, which is the same reason the old env-file code was defensible.
  • pin_apply_test.ts:244Deno.errors.IsADirectory is POSIX-specific. Opening a directory on Windows surfaces as an access-denied error, and README documents a Windows checkout as a supported dev environment; CI is Linux, so this would only ever fail on someone's machine. Asserting "not NotFound" plus a non-null throw covers the same rule portably.

Items 3, 4 and 5 of the original five (README clause, HostRootCtx, eligibility docstring) I have no objection to — they say what the code does.

Re-reviewed `cd42477` in a detached worktree, checking every claim against the code rather than the description. `deno task check`, `deno lint` and `deno fmt --check` are clean here. `deno task test` gives 488 passed / 2 failed / 1 ignored — both failures are `external_modules_test.ts` dying on `gpg: signing failed: Timeout` in my environment, not this branch; 490 total matches your count. I reverted each of the four new tests' fixes one at a time: `envFiles: splitLabel(first.labels?.[…])` fails both `composeProject` env tests, dropping `...p.envFiles` from the resolve loop fails both eligibility tests, and `if (restore.has(file)) throw e;` fails both `pin_apply` tests. All four are load-bearing, as claimed. What holds: the README's host-exec claims are all true against `hostexec.ts` — `hostRoot === "/"` really does reduce `candidates` to `["direct"]` (`#runProbe`), the proof really is `sh -c 'test ! -e /.dockerenv …'` (`#tryStrategy`), and `host command execution unavailable` really is what the three shelling-out features hit (`actions.ts:398`/`512`, `compose_update.ts:387`, `commit.ts`), while stacks/files/logs go through `dockerFetch` and work either way. `joinHostPath`'s new closing sentence checks out: `hostRoot.replace(/\/+$/, "")` turns `"/"` into `""`, so the drive refusal is genuinely unreachable once the documented fix is applied. `HostStrategy` is a real four-member union in `packages/sdk/mod.ts:72` and the readonly-to-mutable structural pass compiles. The `Deno.errors.NotFound` narrowing is the right test for the only production `PinFs`, and the secrecy claim in the eligibility docstring is accurate — `composeAction` sets `expose: ["entities"]`, so a verdict carrying an env-file path stays admin-only. Item 1 is smaller than the write-up suggests, though: `hostFs.read` is still a non-async arrow calling `hostPath(file)` before `Deno.readTextFile`, so it still throws synchronously and the drive-path refusal was never actually swallowed in production. What the narrowing really buys is the permission-denied and `IsADirectory` cases — which is worth having, and is also a behaviour change nothing in the PR body names: a pin over a stack with one unreadable override file used to skip it and proceed, and now fails the run. The env-file work is where I want changes. Item 1 of the follow-up comment fixed the derivation's *stability* and in doing so gave away its *intent*, and the same coin flip it removes is still live one function away. ### 1. A stale env file can override the current one, and container names decide which wins `projectEnvFiles` unions across containers in `localeCompare(name)` order, and the docstring correctly states that for duplicate keys the last `--env-file` wins. So the merge precedence between two *different* env files is now decided by container name. Repro, run against this branch: ``` containers: media-api-1 (createdAt 2000, label new.env) media-zz-1 (createdAt 1000, label old.env) envFiles: [ "/opt/stacks/media/new.env", "/opt/stacks/media/old.env" ] argv: --env-file /opt/stacks/media/new.env --env-file /opt/stacks/media/old.env last wins -> /opt/stacks/media/old.env ``` That is the real shape, not a contrived one: the operator changes `--env-file` from `old.env` to `new.env` and runs `compose up -d`; compose recreates only the services whose config changed, so an untouched service keeps a container carrying the old label. Under `containers[0]` the newest container's label won, which was unstable but at least tracked the operator's most recent intent. Under the union, the values they deliberately moved away from come back and, if the stale container sorts later, override the new ones. Rename the services and the answer flips. `ContainerInfo` already carries `createdAt`, which is the field this decision actually wants. Either sort by `createdAt` (newest last, so newest wins, and the union is at least ordered by recency), or take the newest container that *has* the label — stable and intent-preserving, and it keeps the `exchange` case you cite, since 1 of 31 containers carrying the label is still the only evidence there is. While that is open: `localeCompare` is the wrong comparator for an order you have declared semantic. It is collation-dependent — `["media-DB-1","media-api-1"]` sorts to `["media-api-1","media-DB-1"]` under `localeCompare` and the other way under code-unit order. A precedence decision should not move with ICU data or locale. ### 2. The union widens argv on the path you documented as having no fallback `composeConfigArgs` is `projectArgs` plus `config -q`, so both pin actions (`actions.ts:397`, `actions.ts:511`) now pass every unioned `--env-file` into `h.step("validate")`. A stack with one stale container whose label names a since-deleted env file previously validated fine when the newest container had no label; now compose exits non-zero on an `--env-file` it cannot open, the pin fails, and the compensator rolls back an edit that would have applied. The docstring's cost analysis covers only the graceful eligibility path — the ungraceful one takes the same widening and gets a hard failure out of it. Please either say so there or reconsider the union in favour of the newest-labelled-container rule, which does not widen anything. ### 3. The stack page still reads the label off `containers[0]` `mod.ts:633` (not in this diff): ```ts const wanted = wantedEnvFiles( first?.labels?.["com.docker.compose.project.environment_file"], ... ``` `wantedEnvFiles` normalises the *spelling* of the label; it has no opinion about *which container* the label came from. So the exact coin flip your docstring describes still governs what the stack page lists — and now the page and the updater derive the set differently, so a stack can show one env file and be validated against two. The PR body's framing ("the same label got a dedicated normalising function in `wantedEnvFiles` for the stack page while staying raw for the invocation") reads as if the page side was already handled; it wasn't. Whichever rule item 1 lands on, `composeProject` should be the one place that answers this, with `collectStackFiles` taking the answer from it. ### 4. The docstring's cited cause cannot happen > compose records it only on containers created by an invocation that passed `--env-file`, so recreating one service without it (the API updater does exactly that) leaves the project split. The API updater does not do that. `update.ts:220` builds `createBody = { ...inspect.Config, Image: image, HostConfig: hostConfig, … }`, and `inspect.Config.Labels` rides along, so a recreate through the engine API preserves the compose labels — env-file label included. Splits are real (a partial `compose up -d` without `--env-file`, as in the repro above), so the conclusion survives; the named mechanism is wrong. Given the last round's finding was this PR repeating premises the code already had wrong, a load-bearing docstring asserting a behaviour of a sibling file it does not have seems worth fixing at the source rather than shipping. ### 5. Minor - `compose_update_test.ts:40` — "the env-file label is per-container" states an asymmetry that does not hold as cleanly as written. `config_files` splits the same way: change the `-f` set, run `compose up -d`, and services whose config did not change keep containers carrying the old `config_files` label. Reading it off `containers[0]` is defensible *because* `containers[0]` is the newest, which is the same reason the old env-file code was defensible. - `pin_apply_test.ts:244` — `Deno.errors.IsADirectory` is POSIX-specific. Opening a directory on Windows surfaces as an access-denied error, and README documents a Windows checkout as a supported dev environment; CI is Linux, so this would only ever fail on someone's machine. Asserting "not `NotFound`" plus a non-null throw covers the same rule portably. Items 3, 4 and 5 of the original five (README clause, `HostRootCtx`, eligibility docstring) I have no objection to — they say what the code does.
@ -52,0 +62,4 @@
* one project label that is not uniform across a project's containers:
* compose records it only on containers created by an invocation that passed
* `--env-file`, so recreating one service without it (the API updater does
* exactly that) leaves the project split. containers[0] is raw
Owner

"(the API updater does exactly that)" is not true of this codebase's API updater. update.ts:220 builds createBody = { ...inspect.Config, Image: image, HostConfig: hostConfig, … }, so inspect.Config.Labels — the env-file label included — is carried into the recreate.

Splits are real (a partial compose up -d without --env-file leaves untouched services on the old label), so the conclusion stands; the mechanism named here is wrong. Worth fixing given last round's finding was this PR repeating premises the code already had wrong.

"(the API updater does exactly that)" is not true of this codebase's API updater. `update.ts:220` builds `createBody = { ...inspect.Config, Image: image, HostConfig: hostConfig, … }`, so `inspect.Config.Labels` — the env-file label included — is carried into the recreate. Splits are real (a partial `compose up -d` without `--env-file` leaves untouched services on the old label), so the conclusion stands; the mechanism named here is wrong. Worth fixing given last round's finding was this PR repeating premises the code already had wrong.
@ -52,0 +85,4 @@
function projectEnvFiles(stack: StackInfo): string[] {
const out: string[] = [];
const byName = [...stack.containers].sort((a, b) =>
a.name.localeCompare(b.name)
Owner

Blocking. This sort makes --env-file precedence a function of container name. Proven on this branch: containers media-api-1 (newer, label new.env) and media-zz-1 (older, label old.env) produce --env-file new.env --env-file old.env, and by the last-wins rule this docstring cites, the STALE file wins. Rename the services and the answer flips.

The path there is ordinary: the operator changes --env-file, runs compose up -d, and an unchanged service keeps a container carrying the old label. containers[0] was unstable but tracked the latest intent; the union throws that away.

ContainerInfo.createdAt exists — sort by it (newest last) so recency decides precedence, or take the newest container that HAS the label, which is stable, intent-preserving, and still covers the 1-of-31 exchange case.

Separately: localeCompare is collation-dependent (["media-DB-1","media-api-1"] orders differently under it than under code units). An order you have declared semantic should not move with locale or ICU data.

Blocking. This sort makes `--env-file` precedence a function of container name. Proven on this branch: containers `media-api-1` (newer, label `new.env`) and `media-zz-1` (older, label `old.env`) produce `--env-file new.env --env-file old.env`, and by the last-wins rule this docstring cites, the STALE file wins. Rename the services and the answer flips. The path there is ordinary: the operator changes `--env-file`, runs `compose up -d`, and an unchanged service keeps a container carrying the old label. `containers[0]` was unstable but tracked the latest intent; the union throws that away. `ContainerInfo.createdAt` exists — sort by it (newest last) so recency decides precedence, or take the newest container that HAS the label, which is stable, intent-preserving, and still covers the 1-of-31 `exchange` case. Separately: `localeCompare` is collation-dependent (`["media-DB-1","media-api-1"]` orders differently under it than under code units). An order you have declared semantic should not move with locale or ICU data.
@ -94,0 +157,4 @@
//
// "Here" is the load-bearing word: this guard answers for the callers that
// CHOOSE between compose and the engine API (the update and recreate
// actions). The pin actions build the same argv through composeConfigArgs
Owner

This paragraph is right that the pin actions have no fallback — which is why the union in projectEnvFiles deserves a mention here too. composeConfigArgs is projectArgs + config -q, so both pin actions now hand compose every unioned --env-file. A stack with one stale container pointing at a deleted env file used to validate fine (when the newest container carried no label) and now fails h.step("validate") outright, edit rolled back.

The docstring above weighs the widening only against the graceful path. The ungraceful path takes the same widening and turns it into a failed run.

This paragraph is right that the pin actions have no fallback — which is why the union in `projectEnvFiles` deserves a mention here too. `composeConfigArgs` is `projectArgs` + `config -q`, so both pin actions now hand compose every unioned `--env-file`. A stack with one stale container pointing at a deleted env file used to validate fine (when the newest container carried no label) and now fails `h.step("validate")` outright, edit rolled back. The docstring above weighs the widening only against the graceful path. The ungraceful path takes the same widening and turns it into a failed run.
@ -36,2 +38,3 @@
Deno.test("composeProject reads labels from the first container", () => {
Deno.test("composeProject reads the project labels off the first container", () => {
// configFiles and workingDir only — the env-file label is per-container and
Owner

"the env-file label is per-container" overstates the asymmetry. config_files splits the same way: change the -f set and run compose up -d, and services whose own config did not change keep containers with the old config_files label.

Reading the other two labels off containers[0] is defensible because containers[0] is the newest container — which is exactly the property the old env-file code was relying on too.

"the env-file label is per-container" overstates the asymmetry. `config_files` splits the same way: change the `-f` set and run `compose up -d`, and services whose own config did not change keep containers with the old `config_files` label. Reading the other two labels off `containers[0]` is defensible because `containers[0]` is the newest container — which is exactly the property the old env-file code was relying on too.
@ -200,0 +241,4 @@
(e: Error) => e,
);
assert(
err instanceof Deno.errors.IsADirectory,
Owner

Deno.errors.IsADirectory is POSIX-specific — reading a directory on Windows surfaces as an access-denied error, and README documents a Windows checkout as a supported dev environment. CI is Linux, so this only ever breaks on someone's machine.

Asserting a non-null throw that is NOT Deno.errors.NotFound tests the same rule ("only absence is 'not here'") on every OS.

`Deno.errors.IsADirectory` is POSIX-specific — reading a directory on Windows surfaces as an access-denied error, and README documents a Windows checkout as a supported dev environment. CI is Linux, so this only ever breaks on someone's machine. Asserting a non-null throw that is NOT `Deno.errors.NotFound` tests the same rule ("only absence is 'not here'") on every OS.
thisilike left a comment

Second pass over the same commit (cd42477 — nothing new pushed since my last review). This time against a real daemon and real docker compose 5.5.0 instead of reasoning from the argv, because item 1 of the previous round rests entirely on compose's own merge semantics and neither of us had actually run it. Everything below is a transcript, not an inference.

Result: my finding 1 is worse than I described it, the previous round's derivation is strictly worse than the code it replaced in a case that occurs in normal operation, and the end state is unrecoverable through compose.

The premises, now measured

All four hold, so the parts of the design that rest on them are sound:

  • compose absolutises the label — --env-file old.env from the project dir records /…/envtest/old.env. No relative-label case to worry about, so resolvePathSegments never sees "relative" from this label.
  • an auto-discovered .env is not recorded (label=[] after an up -d with a .env present), so the blast radius really is only stacks deployed with an explicit --env-file.
  • a missing one is fatal to compose: couldn't find env file: /…/envtest/gone.env.
  • last --env-file wins, as your docstring says:
--env-file new.env --env-file old.env   ->  MSG: from-old
--env-file old.env --env-file new.env   ->  MSG: from-new

And /v1.44/containers/json?all=true does answer created-descending, so containers[0] was the newest container — which is the part that matters below.

The repro, end to end

Two services, aaa interpolating ${MSG} and zzz not. Deploy with --env-file old.env; both containers get the label. The operator then moves to new.env and re-ups — compose recreates only aaa, because only its config changed:

odrev48-aaa-1: envfile=…/new.env  created=2026-08-20T21:04:33Z
odrev48-zzz-1: envfile=…/old.env  created=2026-08-20T21:04:20Z

Then they delete the service zzz from the compose file entirely and re-up. Compose leaves the container standing — it is an orphan, and up -d only warns unless --remove-orphans:

odrev48-yyy-1  running   envfile=…/new.env
odrev48-aaa-1  running   envfile=…/new.env
odrev48-zzz-1  running   envfile=…/old.env      <- service no longer exists in the file

Fed through this branch's own code (real labels off the daemon, into composeProject, out through composeArgs):

containers[0] (the rule you replaced): …/new.env
projectEnvFiles (this branch):        [ …/new.env, …/old.env ]

docker compose -p odrev48 --project-directory …/envtest -f …/compose.yaml \
  --env-file …/new.env --env-file …/old.env --ansi never up -d

Run that argv against compose:

this branch's argv       ->  MSG: from-old
containers[0]'s argv     ->  MSG: from-new

So OpsDeck now updates the stack with the values of the env file the operator abandoned, because zzz sorts after aaa and last wins. The rule this replaced got it right. That is not "wider in both directions" — in this case it is simply wrong where the old one was correct, and the old one was correct because containers[0] is the newest container.

And it cannot be healed

Delete the abandoned old.env, which is the natural next thing an operator does, and the same argv gives:

couldn't find env file: /…/envtest/old.env

From there:

  • composeEligibility returns env file not found on host: …/old.env on every update and recreate, permanently. The stack silently loses the compose updater and drops to the engine-API path — the one that recreates from the OLD image's inspect.Config defaults, which is the whole reason the compose path is preferred.
  • every pin's h.step("validate") fails, permanently. That stack can never be pinned again.
  • nothing an operator does through compose fixes it. up -d, down, restart — none of them touch an orphan's labels. The only recovery is docker rm on a container nothing points at, or a --remove-orphans they have no reason to run, and no message anywhere names either.

down is not a fix either, in the sense that matters: the operator who wanted a fix would have to know the container exists.

What I would take instead

The newest container that carries the label. It is stable (createdAt, not daemon order, not localeCompare), it is what containers[0] was already getting right, it fixes the actual coin flip you set out to fix (a newest container that lost the label falls through to the next one that has it, which is your exchange case), and it cannot widen the argv — so items 2 and 3 of my last review stop applying to it. One file in, one answer out, and no orphan from a deploy two months ago gets a vote.

If you want to keep the union, then the orphan case has to be handled explicitly: filter to containers whose service still appears in the compose file, or at minimum order by createdAt so recency wins the merge, and say in the docstring that a stale label can strand a project on the API updater.

The other four findings from the previous review stand unchanged — the mod.ts:633 divergence, the pin-path widening, the update.ts premise in the docstring (createBody = { ...inspect.Config } carries Labels, so the API updater is not what splits a project — this repro is), and the two minors. deno task check / lint / fmt --check are still clean on this commit; test counts as before.

Repro is five commands if you want it; the compose file is two services and the only trick is that exactly one of them interpolates the variable.

Second pass over the same commit (`cd42477` — nothing new pushed since my last review). This time against a real daemon and real `docker compose 5.5.0` instead of reasoning from the argv, because item 1 of the previous round rests entirely on compose's own merge semantics and neither of us had actually run it. Everything below is a transcript, not an inference. Result: my finding 1 is worse than I described it, the previous round's derivation is *strictly worse than the code it replaced* in a case that occurs in normal operation, and the end state is unrecoverable through compose. ### The premises, now measured All four hold, so the parts of the design that rest on them are sound: - compose absolutises the label — `--env-file old.env` from the project dir records `/…/envtest/old.env`. No relative-label case to worry about, so `resolvePathSegments` never sees `"relative"` from this label. - an auto-discovered `.env` is **not** recorded (`label=[]` after an `up -d` with a `.env` present), so the blast radius really is only stacks deployed with an explicit `--env-file`. - a missing one is fatal to compose: `couldn't find env file: /…/envtest/gone.env`. - **last `--env-file` wins**, as your docstring says: ``` --env-file new.env --env-file old.env -> MSG: from-old --env-file old.env --env-file new.env -> MSG: from-new ``` And `/v1.44/containers/json?all=true` does answer created-descending, so `containers[0]` was the *newest* container — which is the part that matters below. ### The repro, end to end Two services, `aaa` interpolating `${MSG}` and `zzz` not. Deploy with `--env-file old.env`; both containers get the label. The operator then moves to `new.env` and re-ups — compose recreates only `aaa`, because only its config changed: ``` odrev48-aaa-1: envfile=…/new.env created=2026-08-20T21:04:33Z odrev48-zzz-1: envfile=…/old.env created=2026-08-20T21:04:20Z ``` Then they delete the service `zzz` from the compose file entirely and re-up. Compose leaves the container standing — it is an orphan, and `up -d` only warns unless `--remove-orphans`: ``` odrev48-yyy-1 running envfile=…/new.env odrev48-aaa-1 running envfile=…/new.env odrev48-zzz-1 running envfile=…/old.env <- service no longer exists in the file ``` Fed through **this branch's own code** (real labels off the daemon, into `composeProject`, out through `composeArgs`): ``` containers[0] (the rule you replaced): …/new.env projectEnvFiles (this branch): [ …/new.env, …/old.env ] docker compose -p odrev48 --project-directory …/envtest -f …/compose.yaml \ --env-file …/new.env --env-file …/old.env --ansi never up -d ``` Run that argv against compose: ``` this branch's argv -> MSG: from-old containers[0]'s argv -> MSG: from-new ``` So OpsDeck now updates the stack with the values of the env file the operator abandoned, because `zzz` sorts after `aaa` and last wins. The rule this replaced got it right. That is not "wider in both directions" — in this case it is simply wrong where the old one was correct, and the old one was correct *because* `containers[0]` is the newest container. ### And it cannot be healed Delete the abandoned `old.env`, which is the natural next thing an operator does, and the same argv gives: ``` couldn't find env file: /…/envtest/old.env ``` From there: - `composeEligibility` returns `env file not found on host: …/old.env` on every update and recreate, permanently. The stack silently loses the compose updater and drops to the engine-API path — the one that recreates from the OLD image's `inspect.Config` defaults, which is the whole reason the compose path is preferred. - every pin's `h.step("validate")` fails, permanently. That stack can never be pinned again. - nothing an operator does *through compose* fixes it. `up -d`, `down`, `restart` — none of them touch an orphan's labels. The only recovery is `docker rm` on a container nothing points at, or a `--remove-orphans` they have no reason to run, and no message anywhere names either. `down` is not a fix either, in the sense that matters: the operator who wanted a fix would have to know the container exists. ### What I would take instead The newest container that carries the label. It is stable (`createdAt`, not daemon order, not `localeCompare`), it is what `containers[0]` was already getting right, it fixes the actual coin flip you set out to fix (a newest container that *lost* the label falls through to the next one that has it, which is your `exchange` case), and it cannot widen the argv — so items 2 and 3 of my last review stop applying to it. One file in, one answer out, and no orphan from a deploy two months ago gets a vote. If you want to keep the union, then the orphan case has to be handled explicitly: filter to containers whose `service` still appears in the compose file, or at minimum order by `createdAt` so recency wins the merge, and say in the docstring that a stale label can strand a project on the API updater. The other four findings from the previous review stand unchanged — the `mod.ts:633` divergence, the pin-path widening, the `update.ts` premise in the docstring (`createBody = { ...inspect.Config }` carries `Labels`, so the API updater is not what splits a project — this repro is), and the two minors. `deno task check` / `lint` / `fmt --check` are still clean on this commit; test counts as before. Repro is five commands if you want it; the compose file is two services and the only trick is that exactly one of them interpolates the variable.
@ -52,0 +85,4 @@
function projectEnvFiles(stack: StackInfo): string[] {
const out: string[] = [];
const byName = [...stack.containers].sort((a, b) =>
a.name.localeCompare(b.name)
Owner

Measured now, not inferred — real daemon, real docker compose 5.5.0.

A project with three containers, after the operator moved from old.env to new.env and later deleted the service zzz from the compose file (its container survives as an orphan; up -d only warns):

yyy-1 -> new.env   aaa-1 -> new.env   zzz-1 -> old.env

Through this branch's code: projectEnvFiles = [new.env, old.env], and compose resolves that argv to MSG: from-old. The rule you replaced (containers[0], which /containers/json?all=true confirms is the NEWEST container) resolves to MSG: from-new.

So in a case that arises from ordinary operation, the union is not wider — it is wrong where containers[0] was right, because zzz sorts last and the last --env-file wins.

Then delete the abandoned old.env and the same argv gives couldn't find env file: …/old.env, permanently: eligibility fails on every update, every pin's validate step fails, and nothing an operator does through compose clears it, because the label lives on an orphan. docker rm is the only recovery and nothing names it.

The newest container that HAS the label fixes the coin flip you were after (a newest container that lost the label falls through), is stable on createdAt, and widens nothing.

Measured now, not inferred — real daemon, real `docker compose 5.5.0`. A project with three containers, after the operator moved from `old.env` to `new.env` and later deleted the service `zzz` from the compose file (its container survives as an orphan; `up -d` only warns): ``` yyy-1 -> new.env aaa-1 -> new.env zzz-1 -> old.env ``` Through this branch's code: `projectEnvFiles` = `[new.env, old.env]`, and compose resolves that argv to `MSG: from-old`. The rule you replaced (`containers[0]`, which `/containers/json?all=true` confirms is the NEWEST container) resolves to `MSG: from-new`. So in a case that arises from ordinary operation, the union is not wider — it is wrong where `containers[0]` was right, because `zzz` sorts last and the last `--env-file` wins. Then delete the abandoned `old.env` and the same argv gives `couldn't find env file: …/old.env`, permanently: eligibility fails on every update, every pin's validate step fails, and nothing an operator does through compose clears it, because the label lives on an orphan. `docker rm` is the only recovery and nothing names it. The newest container that HAS the label fixes the coin flip you were after (a newest container that lost the label falls through), is stable on `createdAt`, and widens nothing.
@ -52,0 +89,4 @@
);
for (const c of byName) {
for (const path of splitLabel(c.labels?.[ENV_FILE_LABEL] ?? "")) {
if (!out.includes(path)) out.push(path);
Owner

Dedupe is on the raw label string, while wantedEnvFiles keys on posixPath(...). Compose writes one consistent spelling per host, so this is not reachable today — but it is the second place the two derivations of this label can disagree (see the mod.ts:633 finding), and compose.ts's docstring documents exactly this bug class for the page: "a raw-key map listed it twice".

If composeProject becomes the single answer, as I suggested, this disappears with it.

Dedupe is on the raw label string, while `wantedEnvFiles` keys on `posixPath(...)`. Compose writes one consistent spelling per host, so this is not reachable today — but it is the second place the two derivations of this label can disagree (see the `mod.ts:633` finding), and `compose.ts`'s docstring documents exactly this bug class for the page: "a raw-key map listed it twice". If `composeProject` becomes the single answer, as I suggested, this disappears with it.
@ -83,0 +133,4 @@
* Two of the three stats are behaviour changes, and the env files are the
* bigger one. A stack whose workingDir is gone but whose compose files are
* still there was eligible before and is not now. A stack with an env file
* that is gone used to reach compose and die on its own
Owner

"That trade only holds because the answer is STABLE" is the sentence the repro contradicts. The answer is now stable and stale: an orphan container from an abandoned deploy holds the label forever, so the verdict is not a coin flip any more — it is a permanent downgrade to the engine-API updater for a stack whose live services are all fine, plus a pin that can never validate again.

Stability was necessary, not sufficient. It has to be stable AND track the deploy that is actually in force.

"That trade only holds because the answer is STABLE" is the sentence the repro contradicts. The answer is now stable and *stale*: an orphan container from an abandoned deploy holds the label forever, so the verdict is not a coin flip any more — it is a permanent downgrade to the engine-API updater for a stack whose live services are all fine, plus a pin that can never validate again. Stability was necessary, not sufficient. It has to be stable AND track the deploy that is actually in force.
fix(docker): the newest labelled container answers for --env-file, alone
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m21s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m10s
ad22ed0913
The union over every container was stable but stale, and a real daemon
shows it is strictly worse than the containers[0] rule it replaced in a
case that arises from ordinary operation.

Reproduced against docker compose 5.5.0: a project deployed with
--env-file old.env, moved to new.env (compose recreates only the
services whose config changed, so an untouched service keeps a container
carrying old.env), then the untouched service deleted from the compose
file — `up -d` leaves its container standing as an orphan with a warning.
Fed through this branch's own composeProject/composeArgs, the union
produced `--env-file new.env --env-file old.env`, and because the last
--env-file wins the stack came up with MSG=from-old. containers[0], which
is the newest container, produced MSG=from-new. Delete the abandoned
old.env and the union's argv fails `config -q` with `couldn't find env
file`, permanently: every update drops to the engine-API updater and no
pin can ever validate again, with no compose command that clears it,
because the label lives on an orphan.

So the rule is now the label off the newest container that HAS one. It
keeps what containers[0] got right (the newest container is the deploy in
force) and fixes what it got wrong (a newest container recreated without
--env-file dropped the whole project's env files while its siblings still
carried the label), and it cannot widen the argv with a path no live
deploy asked for — which matters because composeConfigArgs feeds the same
answer to the pin actions' validate step, where there is no graceful
fallback. Ties break on code-unit name order, not localeCompare: createdAt
is whole seconds and one `up -d` starts a project's services inside one,
and a precedence decision must not move with locale or ICU data.

composeProject is now the only place this question is answered.
wantedEnvFiles takes the set already split, so collectStackFiles feeds it
composeProject(stack).envFiles instead of reading a container label a
second time — the stack page and the compose updater can no longer list
one env file and validate against another. The raw-string dedup that
disagreed with wantedEnvFiles' posixPath keys goes with the union.

Docstring corrections, all from the review: the API updater does NOT drop
the label on a recreate (update.ts builds createBody from
{ ...inspect.Config }, which carries Labels), so the split is named by its
real cause; the eligibility trade needs the answer to be current as well
as stable, and says why; and the pin path's share of the same answer is
written down where the graceful path's cost analysis already was. The
test comment claiming config_files is uniform across a project is
corrected — it splits the same way, and containers[0] is defensible for
it because containers[0] is the newest.

pin_apply_test's non-absence assertion no longer names
Deno.errors.IsADirectory, which is POSIX-specific while README documents a
Windows checkout as supported. "Threw, and not NotFound" is the whole rule
and holds on every OS; still fails if the narrowing is reverted.

deno task check, deno lint, deno fmt --check clean; deno task test 492
passed / 0 failed / 1 ignored. Each new test verified load-bearing by
reverting its fix in isolation.

Refs #20, #22, #47.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Pushed ad22ed0. Took the rule you asked for — the newest container that HAS the label — and ran your repro against a real daemon before writing it, because you were right that neither of us should be reasoning from the argv.

Your repro, reproduced

docker compose 5.5.0, engine 29.7.2. Two services, aaa interpolating ${MSG}, zzz not. Deploy on old.env; both containers get the label. Move to new.env and re-up — compose recreates only aaa:

odpr48-aaa-1 | 2026-08-20T22:51:52Z | …/envtest/new.env
odpr48-zzz-1 | 2026-08-20T22:51:44Z | …/envtest/old.env

Delete the service zzz from the compose file and re-up. Compose leaves the container standing, warning only:

level=warning msg="Found orphan containers (odpr48-zzz-1) for this project…"

Those real labels through the branch's own composeProjectcomposeArgs, then each argv run against compose:

newest-labelled (this commit)  --env-file new.env                    -> MSG=from-new
union (what you flagged)       --env-file new.env --env-file old.env -> MSG=from-old

And after deleting the abandoned old.env, the validate step both pin actions run:

newest-labelled  config -q  exit=0
union            config -q  exit=1   couldn't find env file: …/old.env

So: confirmed in full, including the part I would have argued with. The union was not wider, it was wrong where containers[0] was right, and the end state is exactly as unrecoverable as you described.

What changed

1 — projectEnvFiles is the newest labelled container. Not containers[0], not a union. It keeps what containers[0] had right (the newest container is the deploy in force) and fixes what it had wrong (a newest container recreated without --env-file zeroed the project's env files while 30 siblings carried the label — the exchange case), and it cannot put a path on the command line that no live deploy asked for. Ties break on code-unit name order, not localeCompare — you are right that a semantic order must not move with ICU data, and ties are ordinary here since createdAt is Created * 1000, a whole second, and one up -d starts a project's services inside one. The test row uses media-DB-1 / media-aaa-1 precisely because the two comparators disagree on it, so it fails if the comparator drifts back.

2 — findings 2 and 3 of your first review stop applying, and the pin path is written down anyway. Nothing widens now, but composeConfigArgs still carries whatever this answers into h.step("validate") with no fallback, so the eligibility docstring says so next to the graceful path's cost analysis — including the one case this rule does add an argument containers[0] did not, and why that argument names the file the running services were interpolated with.

3 — composeProject is the single answer. wantedEnvFiles now takes the set already split, so collectStackFiles passes composeProject(stack)?.envFiles ?? [] instead of reading first?.labels?.[…] a second time at mod.ts:633. The page and the updater can no longer derive it differently. The raw-string dedup you flagged in the second review goes with the union — there is one label now, passed through verbatim, so compose's own last-wins ordering survives and there is nothing to key inconsistently.

4 — the wrong premise is gone. You are right: update.ts:220 builds createBody = { ...inspect.Config, … } and Labels rides along, so the API updater is not what splits a project. The docstring no longer names it; the split is attributed to the partial up -d, which is what the repro above actually does.

5 — stability was necessary, not sufficient, and the eligibility docstring now says that in those terms: an orphan's label is perfectly stable and turns the graceful downgrade permanent. The trade only holds because the set stat'd is one live deploy's.

Minors. The config_files comment is corrected — it splits the same way, and reading it off containers[0] is defensible because containers[0] is the newest, which is your point exactly. pin_apply_test.ts no longer names Deno.errors.IsADirectory; it asserts a non-null throw that is not NotFound, which is the whole rule and holds on Windows. Still fails when the narrowing is reverted.

On item 1 being smaller than the write-up claimed: agreed, and the behaviour change you named — a pin over a stack with one unreadable override file used to skip it and now fails the run — is real and was not in the body. It is deliberate (a silent skip there means reasoning from a stale copy on the write path), but it should have been stated, so it is in the commit message.

Verification

deno task check, deno lint, deno fmt --check clean. deno task test492 passed | 0 failed | 1 ignored.

Every new test verified load-bearing by reverting its fix in isolation rather than assuming:

revert fails
back to the localeCompare union newest-labelled test, tie-break test
tie-break → localeCompare tie-break test
drop the has-a-label filter (newest container, full stop) recreation-order test, page/updater seam test
if (restore.has(file)) throw e; both pin_apply tests

The repro is five commands and I still have them if you want the compose file; the only trick, as you said, is that exactly one service interpolates the variable.

🤖 Generated with Claude Code

Pushed `ad22ed0`. Took the rule you asked for — the newest container that HAS the label — and ran your repro against a real daemon before writing it, because you were right that neither of us should be reasoning from the argv. ### Your repro, reproduced `docker compose 5.5.0`, engine 29.7.2. Two services, `aaa` interpolating `${MSG}`, `zzz` not. Deploy on `old.env`; both containers get the label. Move to `new.env` and re-up — compose recreates only `aaa`: ``` odpr48-aaa-1 | 2026-08-20T22:51:52Z | …/envtest/new.env odpr48-zzz-1 | 2026-08-20T22:51:44Z | …/envtest/old.env ``` Delete the service `zzz` from the compose file and re-up. Compose leaves the container standing, warning only: ``` level=warning msg="Found orphan containers (odpr48-zzz-1) for this project…" ``` Those real labels through the branch's own `composeProject` → `composeArgs`, then each argv run against compose: ``` newest-labelled (this commit) --env-file new.env -> MSG=from-new union (what you flagged) --env-file new.env --env-file old.env -> MSG=from-old ``` And after deleting the abandoned `old.env`, the validate step both pin actions run: ``` newest-labelled config -q exit=0 union config -q exit=1 couldn't find env file: …/old.env ``` So: confirmed in full, including the part I would have argued with. The union was not wider, it was wrong where `containers[0]` was right, and the end state is exactly as unrecoverable as you described. ### What changed **1 — `projectEnvFiles` is the newest labelled container.** Not `containers[0]`, not a union. It keeps what `containers[0]` had right (the newest container is the deploy in force) and fixes what it had wrong (a newest container recreated without `--env-file` zeroed the project's env files while 30 siblings carried the label — the `exchange` case), and it cannot put a path on the command line that no live deploy asked for. Ties break on **code-unit** name order, not `localeCompare` — you are right that a semantic order must not move with ICU data, and ties are ordinary here since `createdAt` is `Created * 1000`, a whole second, and one `up -d` starts a project's services inside one. The test row uses `media-DB-1` / `media-aaa-1` precisely because the two comparators disagree on it, so it fails if the comparator drifts back. **2 — findings 2 and 3 of your first review stop applying, and the pin path is written down anyway.** Nothing widens now, but `composeConfigArgs` still carries whatever this answers into `h.step("validate")` with no fallback, so the eligibility docstring says so next to the graceful path's cost analysis — including the one case this rule *does* add an argument `containers[0]` did not, and why that argument names the file the running services were interpolated with. **3 — `composeProject` is the single answer.** `wantedEnvFiles` now takes the set **already split**, so `collectStackFiles` passes `composeProject(stack)?.envFiles ?? []` instead of reading `first?.labels?.[…]` a second time at `mod.ts:633`. The page and the updater can no longer derive it differently. The raw-string dedup you flagged in the second review goes with the union — there is one label now, passed through verbatim, so compose's own last-wins ordering survives and there is nothing to key inconsistently. **4 — the wrong premise is gone.** You are right: `update.ts:220` builds `createBody = { ...inspect.Config, … }` and `Labels` rides along, so the API updater is not what splits a project. The docstring no longer names it; the split is attributed to the partial `up -d`, which is what the repro above actually does. **5 — stability was necessary, not sufficient**, and the eligibility docstring now says that in those terms: an orphan's label is perfectly stable and turns the graceful downgrade permanent. The trade only holds because the set stat'd is one live deploy's. **Minors.** The `config_files` comment is corrected — it splits the same way, and reading it off `containers[0]` is defensible *because* `containers[0]` is the newest, which is your point exactly. `pin_apply_test.ts` no longer names `Deno.errors.IsADirectory`; it asserts a non-null throw that is not `NotFound`, which is the whole rule and holds on Windows. Still fails when the narrowing is reverted. On item 1 being smaller than the write-up claimed: agreed, and the behaviour change you named — a pin over a stack with one unreadable override file used to skip it and now fails the run — is real and was not in the body. It is deliberate (a silent skip there means reasoning from a stale copy on the write path), but it should have been stated, so it is in the commit message. ### Verification `deno task check`, `deno lint`, `deno fmt --check` clean. `deno task test` → **492 passed | 0 failed | 1 ignored**. Every new test verified load-bearing by reverting its fix in isolation rather than assuming: | revert | fails | | --- | --- | | back to the `localeCompare` union | newest-labelled test, tie-break test | | tie-break → `localeCompare` | tie-break test | | drop the has-a-label filter (newest container, full stop) | recreation-order test, page/updater seam test | | `if (restore.has(file)) throw e;` | both `pin_apply` tests | The repro is five commands and I still have them if you want the compose file; the only trick, as you said, is that exactly one service interpolates the variable. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
thisilike left a comment

Re-reviewed ad22ed0 in a detached worktree, against a real daemon (engine 29.7.2, compose 5.5.0) rather than from the argv. Everything below is a transcript.

The three items I asked you to fix are fixed, and the verification you claim is real. But the new rule is not the rule I asked for, and it has a case where it is strictly worse than the containers[0] rule it replaced — the mirror image of the orphan bug, reproduced end to end. Two more findings below it.

Verified, not taken on trust

  • deno task check, deno lint, deno fmt --check clean. deno task test492 passed | 0 failed | 1 ignored, exactly your count. (My two external_modules_test.ts failures were my own global commit.gpgsign again — I have fixed that at the source in #49 so neither of us pays for it a third time.)
  • All five fixes are load-bearing. I reverted each in isolation and ran the docker backend suite (229 tests): projectEnvFilescontainers[0] fails 4; tie-break → localeCompare fails 1 (the media-DB-1 row, as designed); dropping the has-a-label filter fails 2; if (restore.has(file)) throw e; fails both pin_apply tests; dropping ...p.envFiles from the resolve loop fails both eligibility tests. Your table is accurate.
  • The seam at mod.ts:639 is closed by the type, not just by a test. Reverting it to first?.labels?.[…] does not compile — TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string[]'. That is a better guard than the test, which exercises composeProject directly and would have passed.
  • HostRootCtx bites. === "dircet"TS2367 … types 'HostStrategy | null' and '"dircet"' have no overlap.
  • Every README host-exec claim is exact. candidates = hostRoot && hostRoot !== "/" ? ["chroot","nsfile","pidns","direct"] : ["direct"] (#runProbe), the proof is sh -c 'test ! -e /.dockerenv && test ! -e /run/.containerenv' (#tryStrategy), direct returns the command verbatim (argv), and the message is host command execution unavailable (HostUnavailableError).
  • The update.ts premise is gone, the config_files comment is corrected, and pin_apply_test.ts no longer duplicates :159 or names Deno.errors.IsADirectory. Secrecy claim holds: composeAction sets expose: ["entities"], so an env-file path in a verdict stays admin-only.
  • The case your rule fixes is real. A docker compose run one-off without --env-file becomes the newest container with no label, and containers[0] answers [] where yours correctly falls through. Confirmed on the daemon.

What does not hold

Details inline. Summary:

  1. An operator who stops passing --env-file gets the abandoned file resurrected — wrong interpolation now, and a permanently un-pinnable stack the moment they delete it. containers[0] was correct in this case. This is the same unrecoverable end state as the orphan bug, reached from the other direction.
  2. The tie-break decides by service name where the daemon already told you the answer. createdAt is Created * 1000 — whole seconds — so two deploys inside one second tie, and the name comparator picks the older label. Reproduced: this branch answers old.env where containers[0] answers new.env.
  3. The orphan hazard is not gone, and neither is one-off pollution — an orphan or a compose run --env-file container that is newest still speaks for the project. The com.docker.compose.oneoff label is right there and unused.
  4. Three load-bearing docstring sentences are false as written.

What I would take

Iterate the containers in the order engine.ts already hands you — the daemon's created-descending /containers/json — skip one-offs, and take the first container's label, present or absent:

function projectEnvFiles(stack: StackInfo): string[] {
  for (const c of stack.containers) {
    if (c.labels?.["com.docker.compose.oneoff"] === "True") continue;
    return splitLabel(c.labels?.[ENV_FILE_LABEL] ?? "");
  }
  return [];
}

No createdAt, no comparator, no tie to break, sub-second accurate, and it cannot invent an argument: absence of the label is an answer — the deploy in force passed no --env-file — rather than a gap to fill from an older container. It keeps the one-off fix that motivated this commit and drops finding 1 entirely.

The cost is honest and yours to weigh: it does not rescue the exchange case, because that case and finding 1 are the same observation read two ways, and nothing in the labels distinguishes "lost the label by accident" from "deliberately stopped passing one". If you keep the fall-through anyway, then finding 1 has to be in the docstring next to the orphan paragraph, with the part that makes it serious: no OpsDeck action can clear the stale label, because plain up -d does not touch it and the compose path that would is the one eligibility has just disabled.

deno task check / lint / fmt --check are clean on this commit and every test is load-bearing; my objection is to the rule, not the workmanship.

Re-reviewed `ad22ed0` in a detached worktree, against a real daemon (engine 29.7.2, compose 5.5.0) rather than from the argv. Everything below is a transcript. The three items I asked you to fix are fixed, and the verification you claim is real. But the new rule is not the rule I asked for, and it has a case where it is **strictly worse than the `containers[0]` rule it replaced** — the mirror image of the orphan bug, reproduced end to end. Two more findings below it. ### Verified, not taken on trust - `deno task check`, `deno lint`, `deno fmt --check` clean. `deno task test` → **492 passed | 0 failed | 1 ignored**, exactly your count. (My two `external_modules_test.ts` failures were my own global `commit.gpgsign` again — I have fixed that at the source in #49 so neither of us pays for it a third time.) - **All five fixes are load-bearing.** I reverted each in isolation and ran the docker backend suite (229 tests): `projectEnvFiles` → `containers[0]` fails 4; tie-break → `localeCompare` fails 1 (the `media-DB-1` row, as designed); dropping the has-a-label filter fails 2; `if (restore.has(file)) throw e;` fails both `pin_apply` tests; dropping `...p.envFiles` from the resolve loop fails both eligibility tests. Your table is accurate. - **The seam at `mod.ts:639` is closed by the type, not just by a test.** Reverting it to `first?.labels?.[…]` does not compile — `TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string[]'`. That is a better guard than the test, which exercises `composeProject` directly and would have passed. - **`HostRootCtx` bites.** `=== "dircet"` → `TS2367 … types 'HostStrategy | null' and '"dircet"' have no overlap`. - **Every README host-exec claim is exact.** `candidates = hostRoot && hostRoot !== "/" ? ["chroot","nsfile","pidns","direct"] : ["direct"]` (`#runProbe`), the proof is `sh -c 'test ! -e /.dockerenv && test ! -e /run/.containerenv'` (`#tryStrategy`), `direct` returns the command verbatim (`argv`), and the message is `host command execution unavailable` (`HostUnavailableError`). - The `update.ts` premise is gone, the `config_files` comment is corrected, and `pin_apply_test.ts` no longer duplicates `:159` or names `Deno.errors.IsADirectory`. Secrecy claim holds: `composeAction` sets `expose: ["entities"]`, so an env-file path in a verdict stays admin-only. - **The case your rule fixes is real.** A `docker compose run` one-off without `--env-file` becomes the newest container with no label, and `containers[0]` answers `[]` where yours correctly falls through. Confirmed on the daemon. ### What does not hold Details inline. Summary: 1. **An operator who stops passing `--env-file` gets the abandoned file resurrected** — wrong interpolation now, and a permanently un-pinnable stack the moment they delete it. `containers[0]` was correct in this case. This is the same unrecoverable end state as the orphan bug, reached from the other direction. 2. **The tie-break decides by service name where the daemon already told you the answer.** `createdAt` is `Created * 1000` — whole seconds — so two deploys inside one second tie, and the name comparator picks the older label. Reproduced: this branch answers `old.env` where `containers[0]` answers `new.env`. 3. **The orphan hazard is not gone**, and neither is one-off pollution — an orphan or a `compose run --env-file` container that is newest still speaks for the project. The `com.docker.compose.oneoff` label is right there and unused. 4. Three load-bearing docstring sentences are false as written. ### What I would take Iterate the containers in the order `engine.ts` already hands you — the daemon's created-descending `/containers/json` — skip one-offs, and take the **first** container's label, present or absent: ```ts function projectEnvFiles(stack: StackInfo): string[] { for (const c of stack.containers) { if (c.labels?.["com.docker.compose.oneoff"] === "True") continue; return splitLabel(c.labels?.[ENV_FILE_LABEL] ?? ""); } return []; } ``` No `createdAt`, no comparator, no tie to break, sub-second accurate, and it cannot invent an argument: absence of the label is an *answer* — the deploy in force passed no `--env-file` — rather than a gap to fill from an older container. It keeps the one-off fix that motivated this commit and drops finding 1 entirely. The cost is honest and yours to weigh: it does not rescue the `exchange` case, because that case and finding 1 are the same observation read two ways, and nothing in the labels distinguishes "lost the label by accident" from "deliberately stopped passing one". If you keep the fall-through anyway, then finding 1 has to be in the docstring next to the orphan paragraph, with the part that makes it serious: **no OpsDeck action can clear the stale label**, because plain `up -d` does not touch it and the compose path that would is the one eligibility has just disabled. `deno task check` / `lint` / `fmt --check` are clean on this commit and every test is load-bearing; my objection is to the rule, not the workmanship.
@ -52,0 +96,4 @@
* `up -d` starts a project's services inside one and a precedence
* decision must not move with locale or ICU data.
*/
function projectEnvFiles(stack: StackInfo): string[] {
Owner

Strictly worse than the rule this replaces, in a case that arises from ordinary operation: the operator stops passing --env-file.

They move the values into the project's .env (compose's own default) and re-up. Compose recreates only the service whose config changed, so the untouched service keeps a container carrying the abandoned label — the same split as your orphan repro, from the other side. Real daemon, compose 5.5.0:

step 1: up -d --env-file old.env
  odrev48a-aaa-1  envfile=…/envtestA/old.env
  odrev48a-zzz-1  envfile=…/envtestA/old.env

step 2: operator drops --env-file and re-ups (.env now supplies MSG)
  Container odrev48a-aaa-1 Recreated
  odrev48a-aaa-1  envfile=            <- newest, label gone
  odrev48a-zzz-1  envfile=…/old.env   <- untouched, keeps it

Fed through this branch's own composeProjectcomposeArgs:

this branch  projectEnvFiles : ["…/envtestA/old.env"]
containers[0] (replaced rule): []

And what compose resolves for each argv:

no --env-file (what the operator just ran): msg=from-dotenv
--env-file old.env (what this branch passes): msg=from-old

So the next OpsDeck update silently reverts their migration: --env-file also suppresses compose's .env discovery, so every service — including aaa, which is running on .env values — gets re-interpolated from the file they abandoned. The docstring's justification for this exact case ("names a file the running services were interpolated with", line 197) is false for aaa.

Then they delete old.env, which is the natural next step, and it is your orphan ending verbatim — except the container holding the stale label is a live service, so filtering orphans would not save it:

eligibility            : env file not found on host: …/envtestA/old.env
pin validate (config -q): exit=1  couldn't find env file: …/envtestA/old.env
operator's own argv (no --env-file): exit=0

Permanent silent downgrade to the engine-API updater, permanently un-pinnable, while their own compose up -d is perfectly healthy. And OpsDeck cannot heal it:

up -d                 -> zzz envfile=…/old.env   (unchanged)
up -d --force-recreate -> zzz envfile=            (cleared)

Only --force-recreate clears the label, composeUp does not pass it, and the compose path is the one eligibility just disabled — so every route back is through the engine API, which preserves inspect.Config.Labels. containers[0] had no such state.

The rule in the review body has none of this: absence of the label is an answer, not a gap.

**Strictly worse than the rule this replaces, in a case that arises from ordinary operation: the operator stops passing `--env-file`.** They move the values into the project's `.env` (compose's own default) and re-up. Compose recreates only the service whose config changed, so the untouched service keeps a container carrying the abandoned label — the same split as your orphan repro, from the other side. Real daemon, compose 5.5.0: ``` step 1: up -d --env-file old.env odrev48a-aaa-1 envfile=…/envtestA/old.env odrev48a-zzz-1 envfile=…/envtestA/old.env step 2: operator drops --env-file and re-ups (.env now supplies MSG) Container odrev48a-aaa-1 Recreated odrev48a-aaa-1 envfile= <- newest, label gone odrev48a-zzz-1 envfile=…/old.env <- untouched, keeps it ``` Fed through this branch's own `composeProject` → `composeArgs`: ``` this branch projectEnvFiles : ["…/envtestA/old.env"] containers[0] (replaced rule): [] ``` And what compose resolves for each argv: ``` no --env-file (what the operator just ran): msg=from-dotenv --env-file old.env (what this branch passes): msg=from-old ``` So the next OpsDeck update silently reverts their migration: `--env-file` also suppresses compose's `.env` discovery, so every service — including `aaa`, which is *running* on `.env` values — gets re-interpolated from the file they abandoned. The docstring's justification for this exact case ("names a file the running services were interpolated with", line 197) is false for `aaa`. Then they delete `old.env`, which is the natural next step, and it is your orphan ending verbatim — except the container holding the stale label is a live service, so filtering orphans would not save it: ``` eligibility : env file not found on host: …/envtestA/old.env pin validate (config -q): exit=1 couldn't find env file: …/envtestA/old.env operator's own argv (no --env-file): exit=0 ``` Permanent silent downgrade to the engine-API updater, permanently un-pinnable, while their own `compose up -d` is perfectly healthy. And **OpsDeck cannot heal it**: ``` up -d -> zzz envfile=…/old.env (unchanged) up -d --force-recreate -> zzz envfile= (cleared) ``` Only `--force-recreate` clears the label, `composeUp` does not pass it, and the compose path is the one eligibility just disabled — so every route back is through the engine API, which preserves `inspect.Config.Labels`. `containers[0]` had no such state. The rule in the review body has none of this: absence of the label is an answer, not a gap.
@ -52,0 +88,4 @@
* `--env-file` is the deploy in force. It fixes what containers[0] got wrong
* a newest container that lost the label falls through to the next one
* that has it without ever putting a path on the command line that no
* live deploy asked for. Within the one label compose's own comma order is
Owner

"without ever putting a path on the command line that no live deploy asked for" — this does not hold, and the orphan case is not closed.

The filter is "has a label", not "is a service of this project", so an orphan still gets a vote whenever it is the newest labelled container. That is easy to reach: a service added later with a different env file, then deleted from the compose file.

step 1: only aaa in the file        -> up -d --env-file old.env
  odrev48e-aaa-1  created=…T14:51:52Z  envfile=…/old.env
step 2: zzz added                   -> up -d --env-file new.env
  odrev48e-zzz-1  created=…T14:51:54Z  envfile=…/new.env   <- newer
  odrev48e-aaa-1  created=…T14:51:52Z  envfile=…/old.env   (not recreated)
step 3: zzz deleted from the file   -> up -d --env-file old.env
  warning: Found orphan containers (odrev48e-zzz-1) …

this branch  projectEnvFiles : ["…/envtestE/new.env"]   <- the orphan's

The deploy in force passes old.env; the branch passes new.env. Delete new.env and it is the permanent ending again — eligibility: env file not found, config -q exit=1, operator's own argv exit 0.

Same for one-offs, which the label already distinguishes and this ignores:

docker compose … --env-file old.env run --no-deps -d aaa sleep 300
  odrev48e-aaa-run-2df50cf9  oneoff=True  envfile=…/old.env   <- newest labelled

A debugging compose run --env-file scratch.env therefore sets the project's env files for every later update and pin, and the exited container keeps doing so (listStacks passes all=true). Skipping com.docker.compose.oneoff === "True" is one line and is the same one that makes the rule in the review body work.

**"without ever putting a path on the command line that no live deploy asked for" — this does not hold, and the orphan case is not closed.** The filter is "has a label", not "is a service of this project", so an orphan still gets a vote whenever it is the newest labelled container. That is easy to reach: a service added later with a different env file, then deleted from the compose file. ``` step 1: only aaa in the file -> up -d --env-file old.env odrev48e-aaa-1 created=…T14:51:52Z envfile=…/old.env step 2: zzz added -> up -d --env-file new.env odrev48e-zzz-1 created=…T14:51:54Z envfile=…/new.env <- newer odrev48e-aaa-1 created=…T14:51:52Z envfile=…/old.env (not recreated) step 3: zzz deleted from the file -> up -d --env-file old.env warning: Found orphan containers (odrev48e-zzz-1) … this branch projectEnvFiles : ["…/envtestE/new.env"] <- the orphan's ``` The deploy in force passes `old.env`; the branch passes `new.env`. Delete `new.env` and it is the permanent ending again — `eligibility: env file not found`, `config -q exit=1`, operator's own argv exit 0. Same for one-offs, which the label already distinguishes and this ignores: ``` docker compose … --env-file old.env run --no-deps -d aaa sleep 300 odrev48e-aaa-run-2df50cf9 oneoff=True envfile=…/old.env <- newest labelled ``` A debugging `compose run --env-file scratch.env` therefore sets the project's env files for every later update and pin, and the exited container keeps doing so (`listStacks` passes `all=true`). Skipping `com.docker.compose.oneoff === "True"` is one line and is the same one that makes the rule in the review body work.
@ -52,0 +102,4 @@
if (!splitLabel(c.labels?.[ENV_FILE_LABEL] ?? "").length) continue;
const better = !newest ||
c.createdAt > newest.createdAt ||
(c.createdAt === newest.createdAt && c.name < newest.name);
Owner

The tie-break re-derives recency from a coarser field than the one you were handed, and gets it wrong.

createdAt is Created * 1000 (engine.ts:156) — whole seconds — and the docstring says ties are ordinary for exactly that reason. But /containers/json is answered created-descending, which is what makes containers[0] the newest container and is the premise the paragraph above rests on. Sorting by the second-resolution field throws that ordering away, and the name comparator that replaces it has nothing to do with recency.

Two deploys inside one second, real daemon, the changed service named zzz:

odrev48t-zzz-1  created=…T14:55:54.907Z  envfile=…/new.env   <- current deploy
odrev48t-aaa-1  created=…T14:55:54.735Z  envfile=…/old.env

both arrive as createdAt=1787324154000 — a tie

this branch  projectEnvFiles : ["…/envtestT/old.env"]
containers[0] (replaced rule): ["…/envtestT/new.env"]

Name order picks aaa, so the abandoned file wins. localeCompare versus code units is not the axis that matters here; both are wrong, and the test at compose_update_test.ts pins the comparator rather than the property.

Iterating in the order engine.ts hands you and taking the first labelled container needs no comparator, no tie-break and no createdAt — and it is sub-second accurate. If that order is not trustworthy enough to decide this, it is not trustworthy enough for configFiles and workingDir either, and composeProject has been reading those off containers[0] all along.

**The tie-break re-derives recency from a coarser field than the one you were handed, and gets it wrong.** `createdAt` is `Created * 1000` (`engine.ts:156`) — whole seconds — and the docstring says ties are ordinary for exactly that reason. But `/containers/json` is answered created-**descending**, which is what makes `containers[0]` the newest container and is the premise the paragraph above rests on. Sorting by the second-resolution field throws that ordering away, and the name comparator that replaces it has nothing to do with recency. Two deploys inside one second, real daemon, the changed service named `zzz`: ``` odrev48t-zzz-1 created=…T14:55:54.907Z envfile=…/new.env <- current deploy odrev48t-aaa-1 created=…T14:55:54.735Z envfile=…/old.env both arrive as createdAt=1787324154000 — a tie this branch projectEnvFiles : ["…/envtestT/old.env"] containers[0] (replaced rule): ["…/envtestT/new.env"] ``` Name order picks `aaa`, so the abandoned file wins. `localeCompare` versus code units is not the axis that matters here; both are wrong, and the test at `compose_update_test.ts` pins the comparator rather than the property. Iterating in the order `engine.ts` hands you and taking the first labelled container needs no comparator, no tie-break and no `createdAt` — and it is sub-second accurate. If that order is not trustworthy enough to decide this, it is not trustworthy enough for `configFiles` and `workingDir` either, and `composeProject` has been reading those off `containers[0]` all along.
@ -83,0 +159,4 @@
* the operator has every reason to delete, and once they do, this returns a
* reason on every update forever while all the live services are fine.
* projectEnvFiles is what supplies both; the graceful path is only graceful
* because the set it stats is one live deploy's.
Owner

"the graceful path is only graceful because the set it stats is one live deploy's" — that is the load-bearing sentence for trading compose's loud couldn't find env file for a silent downgrade, and the set is not always one live deploy's. It can be an orphan's (finding 3) or a file the deploy in force does not pass at all (finding 1), and in both the downgrade is permanent with nothing in the UI naming the container that caused it.

Whichever rule you land on, this paragraph should say what an operator does about it. Right now the only recovery is docker rm on a container nothing points at, or a compose up -d --force-recreate they have no reason to run — and neither is named anywhere, including in the h.log line the verdict produces.

"the graceful path is only graceful because the set it stats is one live deploy's" — that is the load-bearing sentence for trading compose's loud `couldn't find env file` for a silent downgrade, and the set is not always one live deploy's. It can be an orphan's (finding 3) or a file the deploy in force does not pass at all (finding 1), and in both the downgrade is permanent with nothing in the UI naming the container that caused it. Whichever rule you land on, this paragraph should say what an operator does about it. Right now the only recovery is `docker rm` on a container nothing points at, or a `compose up -d --force-recreate` they have no reason to run — and neither is named anywhere, including in the `h.log` line the verdict produces.
fix(docker): the newest container answers for --env-file, label or no label
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m20s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m10s
5be0327fd7
The fall-through to an older labelled sibling read a deliberate drop of
--env-file as an accident: an operator who moves values into the
project's .env leaves untouched services holding the abandoned label,
and resurrecting that file re-interpolates every service from it —
--env-file suppresses compose's own .env discovery — then strands the
stack on the API updater for good once the file is deleted. Absence is
an answer, so the first non-one-off container's label is taken verbatim.
The daemon's created-descending order replaces the createdAt/name
tie-break (createdAt is whole seconds; a name is not recency), and
one-offs are skipped by their own label so a debugging `compose run
--env-file scratch.env` cannot set the project's env files.

A stale label can still speak for the project (an orphan, or a service
compose had no reason to recreate), and no OpsDeck action can clear it:
plain `up -d` never rewrites a standing container's labels, and the
compose invocation that would is on the path eligibility disables. The
missing-env-file verdict therefore names the container the label lives
on and the host-side commands that clear it (--force-recreate, or
--remove-orphans for an orphaned holder), threaded through as
ComposeProject.envFilesFrom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

@thisilike Round 4, at 5be0327. Took your rule, verbatim — no variant of mine this time.

Finding 1 (resurrection on a deliberate --env-file drop) + finding 2 (tie-break)

projectEnvFiles is now exactly the shape from your review body: iterate stack.containers in the order engine.ts hands over (the daemon's created-descending answer), skip com.docker.compose.oneoff === "True", return the first remaining container's label verbatim — present or absent. No fall-through, no union, no comparator, no createdAt. Your .env-migration repro answers [] on this commit: the recreated label-less service is the newest non-one-off, and absence is its answer.

The exchange case is given up, and the docstring owns that on your terms: nothing in the labels distinguishes "lost the label by accident" from "deliberately stopped passing one", and resurrecting an abandoned file is the worse mistake of the two. The tie-break and its comparator test died with the rule; the docstring now argues against re-deriving recency from createdAt (whole seconds) instead of for a tie-break.

Tests: "an absent label on the newest container is an answer, not a gap" fails with the fall-through restored; "the newest container's env-file answer is taken verbatim, no older label joins it" fails with the union restored.

Finding 3 (one-offs, orphans)

One-offs skipped by the label — the one line. "A one-off never speaks for the project's env files" fails with the skip removed (as does the rebuilt seam test in compose_test.ts, whose old fixture assumed the fall-through and now uses the one-off case).

The orphan-that-is-newest case is deliberately not filtered — your rule takes the newest non-one-off verbatim, and an orphan can be it. That hazard is now stated in both docstrings instead of being claimed away: the "without ever putting a path on the command line that no live deploy asked for" sentence is deleted, and projectEnvFiles' docstring ends on the stale-label case and points at eligibility for what it costs.

Finding 4 (three false docstring sentences, recovery unnamed)

All three sentences are gone — "without ever putting a path…", "…the file the running services were interpolated with", "…the set it stats is one live deploy's". The eligibility paragraph you quoted now says: stability is what projectEnvFiles supplies; a set that is always the deploy in force is what no label rule can; the downgrade is PERMANENT because no OpsDeck action rewrites a standing container's labels and the compose invocation that would is on the path the verdict disables.

And the recovery is in the verdict itself now, not just the docstring: composeProject threads envFilesFrom (the answering container's name, only when there are env files) and a missing env file reads

env file not found on host: /opt/stacks/media/old.env — named by the compose label on media-zzz-1; if that file is gone for good, docker compose up -d --force-recreate rewrites the label, or remove the container if it is an orphan (docker compose up -d --remove-orphans)

which reaches the run log through the existing compose unavailable (${reason}) line — admin-only, same as before (expose: ["entities"]). The old exact-string test still passes unchanged (no envFilesFrom, no suffix); a new test pins the container name and both recovery commands.

Verification

deno task check, deno lint, deno fmt --check clean. deno task test: 493 passed | 1 failed — the failure is external_modules_test.ts "a module that loads after the app is built still gets its API routes", pre-existing on this Windows checkout (environment, not the diff). Load-bearing, each revert in isolation against the two compose test files: drop the one-off skip → 2 fail, restore the fall-through → 1 fails, restore the union → 3 fail, drop the verdict suffix → 1 fails.

PR description rewritten: item 2 now describes this rule and both owned costs, item 1 names the unreadable-override behaviour change, item 5 owns the stale-label permanence.

@thisilike Round 4, at `5be0327`. Took your rule, verbatim — no variant of mine this time. ### Finding 1 (resurrection on a deliberate `--env-file` drop) + finding 2 (tie-break) `projectEnvFiles` is now exactly the shape from your review body: iterate `stack.containers` in the order engine.ts hands over (the daemon's created-descending answer), skip `com.docker.compose.oneoff === "True"`, return the first remaining container's label verbatim — present or absent. No fall-through, no union, no comparator, no `createdAt`. Your `.env`-migration repro answers `[]` on this commit: the recreated label-less service is the newest non-one-off, and absence is its answer. The `exchange` case is given up, and the docstring owns that on your terms: nothing in the labels distinguishes "lost the label by accident" from "deliberately stopped passing one", and resurrecting an abandoned file is the worse mistake of the two. The tie-break and its comparator test died with the rule; the docstring now argues *against* re-deriving recency from `createdAt` (whole seconds) instead of for a tie-break. Tests: "an absent label on the newest container is an answer, not a gap" fails with the fall-through restored; "the newest container's env-file answer is taken verbatim, no older label joins it" fails with the union restored. ### Finding 3 (one-offs, orphans) One-offs skipped by the label — the one line. "A one-off never speaks for the project's env files" fails with the skip removed (as does the rebuilt seam test in `compose_test.ts`, whose old fixture assumed the fall-through and now uses the one-off case). The orphan-that-is-newest case is deliberately *not* filtered — your rule takes the newest non-one-off verbatim, and an orphan can be it. That hazard is now stated in both docstrings instead of being claimed away: the "without ever putting a path on the command line that no live deploy asked for" sentence is deleted, and `projectEnvFiles`' docstring ends on the stale-label case and points at eligibility for what it costs. ### Finding 4 (three false docstring sentences, recovery unnamed) All three sentences are gone — "without ever putting a path…", "…the file the running services were interpolated with", "…the set it stats is one live deploy's". The eligibility paragraph you quoted now says: stability is what `projectEnvFiles` supplies; a set that is *always* the deploy in force is what no label rule can; the downgrade is PERMANENT because no OpsDeck action rewrites a standing container's labels and the compose invocation that would is on the path the verdict disables. And the recovery is in the verdict itself now, not just the docstring: `composeProject` threads `envFilesFrom` (the answering container's name, only when there are env files) and a missing env file reads > env file not found on host: /opt/stacks/media/old.env — named by the compose label on media-zzz-1; if that file is gone for good, docker compose up -d --force-recreate rewrites the label, or remove the container if it is an orphan (docker compose up -d --remove-orphans) which reaches the run log through the existing `compose unavailable (${reason})` line — admin-only, same as before (`expose: ["entities"]`). The old exact-string test still passes unchanged (no `envFilesFrom`, no suffix); a new test pins the container name and both recovery commands. ### Verification `deno task check`, `deno lint`, `deno fmt --check` clean. `deno task test`: **493 passed | 1 failed** — the failure is `external_modules_test.ts` "a module that loads after the app is built still gets its API routes", pre-existing on this Windows checkout (environment, not the diff). Load-bearing, each revert in isolation against the two compose test files: drop the one-off skip → 2 fail, restore the fall-through → 1 fails, restore the union → 3 fail, drop the verdict suffix → 1 fails. PR description rewritten: item 2 now describes this rule and both owned costs, item 1 names the unreadable-override behaviour change, item 5 owns the stale-label permanence.
thisilike left a comment

Re-reviewed 5be0327 in a detached worktree, against a real daemon (engine 29.7.2, compose 5.5.0). Everything below is a transcript, not an inference.

The rule is the one I asked for and the workmanship holds. One finding of substance: the one-off skip was applied to one of the three project labels, so composeProject can now answer from two different containers at once — and a one-off's -f set reaches exactly the same permanent end state we spent two rounds on for --env-file. Reproduced end to end. Two smaller ones below it.

Verified, not taken on trust

  • deno task check, deno lint, deno fmt --check clean. deno task test493 passed | 0 failed | 1 ignored here, so your one failure really is environmental — your count is otherwise exact.
  • All five fixes are load-bearing. Reverted each in isolation against the three docker test files (46 tests): drop the one-off skip → 2 fail; restore the fall-through → 1; restore a union → 4 (your 3, plus the page-seam test — my union deduped differently from yours); drop the verdict suffix → 1; if (restore.has(file)) throw e; → both pin_apply tests.
  • The seam is closed by the type, not just by a test. Reverting mod.ts:640 to first?.labels?.[…] gives TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string[]'. Repo-wide there is now exactly one read of com.docker.compose.project.environment_file, in compose_update.ts.
  • HostRootCtx bites and hostpath.ts is backend-only, so the @opsdeck/sdk type import carries no module-builder exposure. (system.ts:64 still spells strategy: string | null, but that one is a response DTO with no equality riding on it — not the same drift.)
  • Every premise the rule rests on, measured:
    • normal service containers carry com.docker.compose.oneoff=False, compose run containers True — so === "True" is the right test, not a guess about spelling.
    • /containers/json?all=true answers created-descending (Created equal for two services of one up -d, as you say), and listStacks never sorts within a project. containers[0] is the newest.
    • --env-file suppresses .env discovery: with a .env present, --env-file old.envMSG: from-old; no flag → MSG: from-dotenv.
  • The recovery in the new verdict is real, both halves: docker compose up -d --force-recreate with no --env-file rewrote the label to empty on both standing containers, and up -d --remove-orphans removed a genuine orphan. The verdict names commands that do what it says they do.
  • Secrecy unchanged — composeAction sets expose: ["entities"], so an env-file path in a verdict stays admin-only.
  • hostFs.read is still a non-async arrow evaluating hostPath(file) first, so item 1's analysis of what the narrowing buys is right, and the new async-rejection test covers the shape the production one cannot reach.

What does not hold

Details inline.

  1. The one-off skip covers envFiles only, so one compose invocation can now be addressed with one container's -f/--project-directory and another's --env-file — and a throwaway -f override from a debug one-off strands the stack permanently, less clearably than the env-file case. The docstring sentence claiming parity with configFiles/workingDir is false in exactly the case the skip was added for.
  2. README's new clause names the compose updater as failing with host command execution unavailable. It never produces that message — it degrades to the engine API and succeeds. Two of the three items in that list are right.
  3. The cost paragraph says what the rule refuses to do, not what its answer does: "none" is not neutral, it hands the stack to .env discovery.

Items 3, 4 and 5 of the original five (README's host-exec facts, HostRootCtx, the eligibility docstring's graceful-failure paragraph) are accurate as written, and the stale-label permanence paragraph is now the honest version of it.

Re-reviewed `5be0327` in a detached worktree, against a real daemon (engine 29.7.2, compose 5.5.0). Everything below is a transcript, not an inference. The rule is the one I asked for and the workmanship holds. One finding of substance: the one-off skip was applied to **one of the three project labels**, so `composeProject` can now answer from two different containers at once — and a one-off's `-f` set reaches exactly the same permanent end state we spent two rounds on for `--env-file`. Reproduced end to end. Two smaller ones below it. ### Verified, not taken on trust - `deno task check`, `deno lint`, `deno fmt --check` clean. `deno task test` → **493 passed | 0 failed | 1 ignored** here, so your one failure really is environmental — your count is otherwise exact. - **All five fixes are load-bearing.** Reverted each in isolation against the three docker test files (46 tests): drop the one-off skip → 2 fail; restore the fall-through → 1; restore a union → 4 (your 3, plus the page-seam test — my union deduped differently from yours); drop the verdict suffix → 1; `if (restore.has(file)) throw e;` → both `pin_apply` tests. - **The seam is closed by the type, not just by a test.** Reverting `mod.ts:640` to `first?.labels?.[…]` gives `TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string[]'`. Repo-wide there is now exactly one read of `com.docker.compose.project.environment_file`, in `compose_update.ts`. - **`HostRootCtx` bites** and `hostpath.ts` is backend-only, so the `@opsdeck/sdk` type import carries no module-builder exposure. (`system.ts:64` still spells `strategy: string | null`, but that one is a response DTO with no equality riding on it — not the same drift.) - **Every premise the rule rests on, measured:** - normal service containers carry `com.docker.compose.oneoff=False`, `compose run` containers `True` — so `=== "True"` is the right test, not a guess about spelling. - `/containers/json?all=true` answers created-descending (`Created` equal for two services of one `up -d`, as you say), and `listStacks` never sorts within a project. `containers[0]` is the newest. - `--env-file` suppresses `.env` discovery: with a `.env` present, `--env-file old.env` → `MSG: from-old`; no flag → `MSG: from-dotenv`. - **The recovery in the new verdict is real**, both halves: `docker compose up -d --force-recreate` with no `--env-file` rewrote the label to empty on both standing containers, and `up -d --remove-orphans` removed a genuine orphan. The verdict names commands that do what it says they do. - Secrecy unchanged — `composeAction` sets `expose: ["entities"]`, so an env-file path in a verdict stays admin-only. - `hostFs.read` is still a non-async arrow evaluating `hostPath(file)` first, so item 1's analysis of what the narrowing buys is right, and the new async-rejection test covers the shape the production one cannot reach. ### What does not hold Details inline. 1. **The one-off skip covers `envFiles` only**, so one compose invocation can now be addressed with one container's `-f`/`--project-directory` and another's `--env-file` — and a throwaway `-f` override from a debug one-off strands the stack permanently, less clearably than the env-file case. The docstring sentence claiming parity with `configFiles`/`workingDir` is false in exactly the case the skip was added for. 2. **README's new clause names the compose updater as failing with `host command execution unavailable`.** It never produces that message — it degrades to the engine API and succeeds. Two of the three items in that list are right. 3. **The cost paragraph says what the rule refuses to do, not what its answer does**: "none" is not neutral, it hands the stack to `.env` discovery. Items 3, 4 and 5 of the original five (README's host-exec facts, `HostRootCtx`, the eligibility docstring's graceful-failure paragraph) are accurate as written, and the stale-label permanence paragraph is now the honest version of it.
README.md Outdated
@ -56,0 +56,4 @@
through it. With a `sh.exe` on `PATH` (Git for Windows with the Unix tools,
MSYS2) that test passes and host commands run against the Windows `docker` CLI;
with no `sh` at all it fails, the strategy stays unset, and everything that
shells out — compose validation, the compose updater, and the git commit a pin
Owner

"the compose updater" does not fail with that message — it never runs. composeAvailable (mod.ts:118) calls ctx.host.probe() and returns a reason string rather than running anything, and all three compose actions (actions.ts:206, 275, 557) treat a non-null reason as "use the engine API". host command execution unavailable: <reason> comes out of HostExec.run alone (hostexec.ts:56), i.e. the pin's validate and commit steps.

So on the setup this paragraph documents, a stack update succeeds, logging compose unavailable (direct: …) — using the engine API. That is the useful fact for someone setting up a Windows checkout, and it is the opposite of what the sentence says. "compose validation" and "the git commit a pin makes" are both right; drop the updater from the list and say it degrades instead.

"the compose updater" does not fail with that message — it never runs. `composeAvailable` (`mod.ts:118`) calls `ctx.host.probe()` and returns a **reason string** rather than running anything, and all three compose actions (`actions.ts:206`, `275`, `557`) treat a non-null reason as "use the engine API". `host command execution unavailable: <reason>` comes out of `HostExec.run` alone (`hostexec.ts:56`), i.e. the pin's validate and commit steps. So on the setup this paragraph documents, a stack update **succeeds**, logging `compose unavailable (direct: …) — using the engine API`. That is the useful fact for someone setting up a Windows checkout, and it is the opposite of what the sentence says. "compose validation" and "the git commit a pin makes" are both right; drop the updater from the list and say it degrades instead.
@ -50,0 +71,4 @@
*
* /containers/json answers created-descending and engine.ts never sorts
* within a project, so the first container is the newest, at the daemon's
* own resolution. That order is the same trust configFiles and workingDir
Owner

This sentence is now false in the one case the skip exists for. composeProject still reads configFiles and workingDir off stack.containers[0] — including when that container is a one-off, which carries project.config_files and project.working_dir labels of its own. So the two answers can come from two containers.

Real daemon, this branch's own listStackscomposeProjectcomposeArgs:

docker compose -p odrev48c -f compose.yaml -f debug.yaml --env-file scratch.env run -d aaa

newest: odrev48c-oneoff3  oneoff=True  cfg=compose.yaml,debug.yaml  env=scratch.env
composeProject: configFiles=[compose.yaml, debug.yaml]  envFiles=[old.env]  from=odrev48c-aaa-1

argv: compose -p odrev48c --project-directory envtest -f compose.yaml -f debug.yaml \
      --env-file old.env --ansi never up -d --pull always

No deploy ever used that pairing: the -f set is a debugging one-off's, the --env-file is the live deploy's. A stack-update there applies the debug override to every service.

Then the operator deletes debug.yaml, which is what a throwaway override is for:

eligibility:   compose file not found on host: …/envtest/debug.yaml
pin validate:  docker compose … -f compose.yaml -f debug.yaml … config -q
               exit=1  open …/envtest/debug.yaml: no such file or directory

Same unrecoverable end state as the orphan case — permanent engine-API downgrade, permanently unpinnable — and less clearable: compose skips one-offs in orphan detection, so up -d --remove-orphans leaves it standing (verified: it removed a real orphan and left all three one-offs). Only docker rm clears it, and nothing names it — the compose-file verdict has no suffix.

The fix is smaller than the one already in the diff: choose the answering container once, and read all three labels off it.

const first = stack.containers.find((c) => c.labels?.[ONE_OFF_LABEL] !== "True");

projectEnvFiles then collapses to splitLabel(first.labels?.[ENV_FILE_LABEL] ?? ""), the parity sentence above becomes true again, and a project of nothing but one-offs answers null — correctly, there is no deploy to address. Note mod.ts:602 and actions.ts:509 also split containers[0].configFiles themselves, so if the answer is meant to be single-sourced, that is where the other two reads live.

One more thing hiding it: the fixture at compose_update_test.ts:136 gives the one-off the same configFiles as the service container, so no test can see the two labels diverge.

This sentence is now false in the one case the skip exists for. `composeProject` still reads `configFiles` and `workingDir` off `stack.containers[0]` — including when that container is a one-off, which carries `project.config_files` and `project.working_dir` labels of its own. So the two answers can come from two containers. Real daemon, this branch's own `listStacks` → `composeProject` → `composeArgs`: ``` docker compose -p odrev48c -f compose.yaml -f debug.yaml --env-file scratch.env run -d aaa newest: odrev48c-oneoff3 oneoff=True cfg=compose.yaml,debug.yaml env=scratch.env composeProject: configFiles=[compose.yaml, debug.yaml] envFiles=[old.env] from=odrev48c-aaa-1 argv: compose -p odrev48c --project-directory envtest -f compose.yaml -f debug.yaml \ --env-file old.env --ansi never up -d --pull always ``` No deploy ever used that pairing: the `-f` set is a debugging one-off's, the `--env-file` is the live deploy's. A `stack-update` there applies the debug override to every service. Then the operator deletes `debug.yaml`, which is what a throwaway override is for: ``` eligibility: compose file not found on host: …/envtest/debug.yaml pin validate: docker compose … -f compose.yaml -f debug.yaml … config -q exit=1 open …/envtest/debug.yaml: no such file or directory ``` Same unrecoverable end state as the orphan case — permanent engine-API downgrade, permanently unpinnable — and *less* clearable: compose skips one-offs in orphan detection, so `up -d --remove-orphans` leaves it standing (verified: it removed a real orphan and left all three one-offs). Only `docker rm` clears it, and nothing names it — the compose-file verdict has no suffix. The fix is smaller than the one already in the diff: choose the answering container once, and read all three labels off it. ```ts const first = stack.containers.find((c) => c.labels?.[ONE_OFF_LABEL] !== "True"); ``` `projectEnvFiles` then collapses to `splitLabel(first.labels?.[ENV_FILE_LABEL] ?? "")`, the parity sentence above becomes true again, and a project of nothing but one-offs answers null — correctly, there is no deploy to address. Note `mod.ts:602` and `actions.ts:509` also split `containers[0].configFiles` themselves, so if the answer is meant to be single-sourced, that is where the other two reads live. One more thing hiding it: the fixture at `compose_update_test.ts:136` gives the one-off the *same* `configFiles` as the service container, so no test can see the two labels diverge.
@ -50,0 +95,4 @@
*
* The honest cost: a newest container that LOST the label by accident (a
* partial `up -d` without `--env-file`) reads identically to a deliberate
* drop, and this rule answers "none" for both nothing in the labels
Owner

"this rule answers 'none' for both" is where the paragraph stops, and "none" is not neutral — it hands interpolation to compose's .env discovery, which the sentence above correctly says --env-file was suppressing. Measured on the same project:

--env-file old.env  ->  MSG: from-old
(no flag, .env present) -> MSG: from-dotenv

Different values means a different config hash, so the next up -d doesn't just fail to rescue the accident — it recreates the affected services with the .env values, quietly. The mirror hazard two paragraphs up is owned down to "once they delete it, compose would refuse the project outright"; this one deserves the same clause, since it is the direction the rule actually chose.

"this rule answers 'none' for both" is where the paragraph stops, and "none" is not neutral — it hands interpolation to compose's `.env` discovery, which the sentence above correctly says `--env-file` was suppressing. Measured on the same project: ``` --env-file old.env -> MSG: from-old (no flag, .env present) -> MSG: from-dotenv ``` Different values means a different config hash, so the next `up -d` doesn't just fail to rescue the accident — it recreates the affected services with the `.env` values, quietly. The mirror hazard two paragraphs up is owned down to "once they delete it, compose would refuse the project outright"; this one deserves the same clause, since it is the direction the rule actually chose.
fix(docker): the answering container answers for every label, not just one
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m23s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m47s
5974078dbd
Round-5 findings on #48, all three:

The one-off skip covered only the env-file label, so composeProject could
address one invocation with a debugging one-off's -f set and the live
deploy's --env-file — an argv no deploy ever produced, and a deleted
throwaway override then strands the stack even less clearably than the
env-file case, because compose's orphan detection skips one-offs and
`--remove-orphans` will not remove one. answeringContainer now picks the
container once — the first non-one-off in the daemon's created-descending
order — and every read goes through it: composeProject takes all three
labels off it, the stack page (mod.ts) and the pin's validate fallback
(actions.ts) use the same chooser and the shared splitLabel instead of
their own containers[0] splits. A project of nothing but one-offs is not
addressable — correctly, there is no deploy to address. The one-off
fixture now diverges in all three labels, so a read that takes even one
off the wrong container fails the test.

README named the compose updater among the features failing with `host
command execution unavailable` on a shell-less Windows checkout. It never
produces that message — composeAvailable returns a reason and the update
degrades to the engine-API recreate and succeeds. The clause now says
which features degrade and which fail.

The cost paragraph stopped at answering "none" for an accidentally lost
label. "None" is not neutral: it hands interpolation to the `.env`
discovery the label was suppressing, a different value set is a different
config hash, and the next update quietly recreates the affected services
on `.env` values. Owned in composeProject's docstring, next to the orphan
paragraph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

@thisilike Round 5 addressed in 5974078, all three findings.

1. The skip now covers every project label. Your suggested shape, almost verbatim: answeringContainer(stack) picks the first non-one-off container once, and composeProject reads configFiles, workingDir and envFiles all off it — so the half-addressed argv (a one-off's -f debug.yaml with the live deploy's --env-file) cannot be built anymore, and a project of nothing but one-offs answers null. The other two containers[0] reads you located are single-sourced onto the same chooser: the stack page (mod.ts) and the stack-pin validate fallback (actions.ts) both call answeringContainer and the now-exported splitLabel instead of splitting containers[0].configFiles themselves. The parity sentence you flagged is gone with projectEnvFiles itself — the docstring now states the one-container rule and owns the one-off -f stranding case, including that --remove-orphans cannot clear a one-off holder. Your fixture finding is fixed at the root: the one-off in the test now diverges in all three labels (-f set, working dir, env file), so a read that takes even one label off the wrong container fails it — reverting the chooser to containers[0] fails that test, the new only-one-offs test, and the page-seam test.

2. README no longer claims the updater fails. The clause now says what you measured: a stack update never produces host command execution unavailablecomposeAvailable returns the reason and the run degrades to the engine-API recreate, logging compose unavailable … — using the engine API — while a pin's compose validation and its git commit go through host exec directly and do fail with that message.

3. The cost paragraph owns what "none" does. Next to the orphan paragraph, as asked: an absent label hands interpolation to the .env discovery the label was suppressing, a different value set is a different config hash, and the next update quietly recreates the affected services on .env values — the rule's chosen direction, not just its refusal.

Verification on this checkout: deno task check, deno lint, deno fmt --check clean; deno task test 494 passed | 1 failed, the failure being the pre-existing external_modules_test.ts environment case. PR description updated to match.

@thisilike Round 5 addressed in `5974078`, all three findings. **1. The skip now covers every project label.** Your suggested shape, almost verbatim: `answeringContainer(stack)` picks the first non-one-off container once, and `composeProject` reads `configFiles`, `workingDir` and `envFiles` all off it — so the half-addressed argv (a one-off's `-f debug.yaml` with the live deploy's `--env-file`) cannot be built anymore, and a project of nothing but one-offs answers null. The other two `containers[0]` reads you located are single-sourced onto the same chooser: the stack page (`mod.ts`) and the stack-pin validate fallback (`actions.ts`) both call `answeringContainer` and the now-exported `splitLabel` instead of splitting `containers[0].configFiles` themselves. The parity sentence you flagged is gone with `projectEnvFiles` itself — the docstring now states the one-container rule and owns the one-off `-f` stranding case, including that `--remove-orphans` cannot clear a one-off holder. Your fixture finding is fixed at the root: the one-off in the test now diverges in **all three** labels (`-f` set, working dir, env file), so a read that takes even one label off the wrong container fails it — reverting the chooser to `containers[0]` fails that test, the new only-one-offs test, and the page-seam test. **2. README no longer claims the updater fails.** The clause now says what you measured: a stack update never produces `host command execution unavailable` — `composeAvailable` returns the reason and the run degrades to the engine-API recreate, logging `compose unavailable … — using the engine API` — while a pin's compose validation and its git commit go through host exec directly and do fail with that message. **3. The cost paragraph owns what "none" does.** Next to the orphan paragraph, as asked: an absent label hands interpolation to the `.env` discovery the label was suppressing, a different value set is a different config hash, and the next update quietly recreates the affected services on `.env` values — the rule's chosen direction, not just its refusal. Verification on this checkout: `deno task check`, `deno lint`, `deno fmt --check` clean; `deno task test` 494 passed | 1 failed, the failure being the pre-existing `external_modules_test.ts` environment case. PR description updated to match.
thisilike approved these changes 2026-08-21 23:08:00 +02:00
thisilike left a comment

Re-reviewed 5974078 in a detached worktree, against the same daemon (engine 29.7.2, compose 5.5.0). All three findings are fixed, and I reproduced the fix rather than reading it. Approving — three non-blocking follow-ups below, one of which is only prose deep.

The finding, re-measured

Same rig as last round: up -d --env-file old.env, then a one-off with -f compose.yaml -f debug.yaml --env-file scratch.env as the newest container, then debug.yaml deleted. This branch's own listStackscomposeProject:

odr5a-oneoff  oneoff=True   cfg=compose.yaml,debug.yaml  env=scratch.env
odr5a-aaa-1   oneoff=False  cfg=compose.yaml             env=old.env
odr5a-zzz-1   oneoff=False  cfg=compose.yaml             env=old.env

answering:    odr5a-aaa-1
configFiles:  [compose.yaml]   envFiles: [old.env]   from: odr5a-aaa-1
eligibility:  null
pin validate: compose -p odr5a --project-directory envtest2 -f compose.yaml \
              --env-file old.env --ansi never config -q  -> exit 0

The half-addressed argv is unbuildable and the deleted throwaway strands nothing. A project of nothing but one-offs (odr5b) answers answering: (none) / composeProject: null, as claimed.

Verified, not taken on trust

  • deno task check, deno lint, deno fmt --check clean. Tests: 494 passed | 0 failed | 1 ignored — your 494 with the environment failure removed. (Run the test step by hand as deno test -A --ignore=packages/module-builder,.claude,data: a CLI --ignore replaces deno.json's exclude, so deno task test walks .claude/worktrees/* — my problem, not yours, and #33's follow-up.)
  • Reverting composeProject's chooser to stack.containers[0] fails exactly 3: a one-off never speaks for the project — any of its labels, a project of nothing but one-offs is not addressable, and the page-seam test in compose_test.ts. Your table is accurate, and the diverged fixture does what you say — all three labels differ, so a single wrong read fails it.
  • Every docstring sentence I checked is true of the code: answeringContainer really is every project label's source, the two exported reads really do outlive the null verdict (the page lists compose files with no workingDir; the pin fallback builds a bare -f list), and the .env-discovery cost paragraph matches what I measured last round (--env-file old.envfrom-old, no flag → from-dotenv).
  • README now says what the code does: composeAvailable (mod.ts:118) returns a reason without running anything, all three compose actions treat non-null as engine-API, and host command execution unavailable only escapes HostExec.run — the pin's validate and commit. The new "differs by feature" split is exact.
  • docker ps orphan behaviour from last round still applies and is now written down: --remove-orphans removed a real orphan and left every one-off standing, so "will not clear one" is measured, not assumed.

Follow-ups (non-blocking)

  1. The two newly single-sourced reads are guarded by prose only. Revert mod.ts:607 to stack.containers[0]494 passed, 0 failed. Same for actions.ts:511. Inline.
  2. StackDetailPage.svelte:132 is a third read of the same label, off containers[0] raw: subtitle={data.stack.containers[0]?.configFiles ?? ""}. Not in this diff, and configFiles is not redacted, so with a one-off newest the page header names compose.yaml,/tmp/debug.yaml while the file list below it — now correctly the answering container's — lists only compose.yaml. The same disagreement between page and answer, one layer up.
  3. Nit on the pin's validate fallback, inline.
Re-reviewed `5974078` in a detached worktree, against the same daemon (engine 29.7.2, compose 5.5.0). All three findings are fixed, and I reproduced the fix rather than reading it. Approving — three non-blocking follow-ups below, one of which is only prose deep. ### The finding, re-measured Same rig as last round: `up -d --env-file old.env`, then a one-off with `-f compose.yaml -f debug.yaml --env-file scratch.env` as the newest container, then `debug.yaml` deleted. This branch's own `listStacks` → `composeProject`: ``` odr5a-oneoff oneoff=True cfg=compose.yaml,debug.yaml env=scratch.env odr5a-aaa-1 oneoff=False cfg=compose.yaml env=old.env odr5a-zzz-1 oneoff=False cfg=compose.yaml env=old.env answering: odr5a-aaa-1 configFiles: [compose.yaml] envFiles: [old.env] from: odr5a-aaa-1 eligibility: null pin validate: compose -p odr5a --project-directory envtest2 -f compose.yaml \ --env-file old.env --ansi never config -q -> exit 0 ``` The half-addressed argv is unbuildable and the deleted throwaway strands nothing. A project of nothing but one-offs (`odr5b`) answers `answering: (none)` / `composeProject: null`, as claimed. ### Verified, not taken on trust - `deno task check`, `deno lint`, `deno fmt --check` clean. Tests: **494 passed | 0 failed | 1 ignored** — your 494 with the environment failure removed. (Run the test step by hand as `deno test -A --ignore=packages/module-builder,.claude,data`: a CLI `--ignore` replaces `deno.json`'s `exclude`, so `deno task test` walks `.claude/worktrees/*` — my problem, not yours, and #33's follow-up.) - Reverting `composeProject`'s chooser to `stack.containers[0]` fails exactly 3: `a one-off never speaks for the project — any of its labels`, `a project of nothing but one-offs is not addressable`, and the page-seam test in `compose_test.ts`. Your table is accurate, and the diverged fixture does what you say — all three labels differ, so a single wrong read fails it. - Every docstring sentence I checked is true of the code: `answeringContainer` really is every project label's source, the two exported reads really do outlive the null verdict (the page lists compose files with no `workingDir`; the pin fallback builds a bare `-f` list), and the `.env`-discovery cost paragraph matches what I measured last round (`--env-file old.env` → `from-old`, no flag → `from-dotenv`). - README now says what the code does: `composeAvailable` (`mod.ts:118`) returns a reason without running anything, all three compose actions treat non-null as engine-API, and `host command execution unavailable` only escapes `HostExec.run` — the pin's validate and commit. The new "differs by feature" split is exact. - `docker ps` orphan behaviour from last round still applies and is now written down: `--remove-orphans` removed a real orphan and left every one-off standing, so "will not clear one" is measured, not assumed. ### Follow-ups (non-blocking) 1. **The two newly single-sourced reads are guarded by prose only.** Revert `mod.ts:607` to `stack.containers[0]` → **494 passed, 0 failed**. Same for `actions.ts:511`. Inline. 2. **`StackDetailPage.svelte:132` is a third read of the same label**, off `containers[0]` raw: `subtitle={data.stack.containers[0]?.configFiles ?? ""}`. Not in this diff, and `configFiles` is not redacted, so with a one-off newest the page header names `compose.yaml,/tmp/debug.yaml` while the file list below it — now correctly the answering container's — lists only `compose.yaml`. The same disagreement between page and answer, one layer up. 3. Nit on the pin's validate fallback, inline.
@ -508,3 +512,2 @@
await h.step("validate", async () => {
const files = (first?.configFiles ?? "").split(",").map((f) => f.trim())
.filter(Boolean);
const files = splitLabel(first?.configFiles ?? "");
Owner

Nit, pre-existing and only reachable when the chooser answers null (a project of nothing but one-offs, or an answering container with no config_files — e.g. a hand-run container carrying only the project label): files is then [] and the fallback runs docker host-side compose config -q with no -f at all, so compose resolves whatever sits in the host CWD or dies with no configuration file provided: not found. The edits are rolled back either way, but the operator gets an error about the wrong thing.

The container-pin action's fallback already does the better thing — edits.flatMap((e) => ["-f", e.file]), i.e. the files this run actually patched. patches is in scope here and holds exactly that.

Nit, pre-existing and only reachable when the chooser answers null (a project of nothing but one-offs, or an answering container with no `config_files` — e.g. a hand-run container carrying only the project label): `files` is then `[]` and the fallback runs `docker host-side compose config -q` with no `-f` at all, so compose resolves whatever sits in the host CWD or dies with `no configuration file provided: not found`. The edits are rolled back either way, but the operator gets an error about the wrong thing. The container-pin action's fallback already does the better thing — `edits.flatMap((e) => ["-f", e.file])`, i.e. the files this run actually patched. `patches` is in scope here and holds exactly that.
@ -600,0 +604,4 @@
// the same container every compose invocation is addressed from — a
// one-off's own config_files/working_dir labels must not put files on
// this page that no deploy uses (see answeringContainer)
const first = answeringContainer(stack);
Owner

This read and actions.ts:511 are the two the PR body calls single-sourced, and nothing enforces it. Revert either to stack.containers[0] and the whole suite stays green (494 passed | 0 failed) — the page-seam test in compose_test.ts exercises composeProjectwantedEnvFiles directly, not collectStackFiles, so it cannot see this line.

Worth contrasting with the env-file seam you closed one round ago, which a type refuses: reverting wantedEnvFiles' argument gives TS2345. Here the guard is a comment.

Cheapest structural version I can see: give compose_update.ts the file list itself, e.g. projectComposeFiles(stack) = splitLabel(answeringContainer(stack)?.configFiles ?? ""), and have both call sites use it. Then one test covers both, and neither file spells containers[0] or a raw split any more — which is also what would have caught the round-4 divergence at the source.

This read and `actions.ts:511` are the two the PR body calls single-sourced, and nothing enforces it. Revert either to `stack.containers[0]` and the whole suite stays green (`494 passed | 0 failed`) — the page-seam test in `compose_test.ts` exercises `composeProject` → `wantedEnvFiles` directly, not `collectStackFiles`, so it cannot see this line. Worth contrasting with the env-file seam you closed one round ago, which a *type* refuses: reverting `wantedEnvFiles`' argument gives `TS2345`. Here the guard is a comment. Cheapest structural version I can see: give `compose_update.ts` the file list itself, e.g. `projectComposeFiles(stack) = splitLabel(answeringContainer(stack)?.configFiles ?? "")`, and have both call sites use it. Then one test covers both, and neither file spells `containers[0]` or a raw split any more — which is also what would have caught the round-4 divergence at the source.
julian merged commit 9d9c48eeec into main 2026-08-21 23:20:35 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/core!48
No description provided.