Skip to content

fix(render): prune what the composition no longer declares - #1295

Merged
antoinetoussaint-byte merged 6 commits into
mainfrom
fix/render-prunes-undeclared-modules
Nov 4, 2026
Merged

antoinetoussaint-byte merged 6 commits into
mainfrom
fix/render-prunes-undeclared-modules

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Problem

platform-obin removed the proposals solution from modules: (platform-obin#505). After codefly deploy gitops render --all --env blue:

  • its render record deployments/modules/proposals/ stayed, and so did compute's, which no composition declares;
  • the cell file deployments/environments/blue/cells/blue/cell.yaml kept their namespaces, workloads and consumer edges, because deriveCell derived the file from the directory listing of deployments/modules/, not from the composition;
  • their delivered trees deployments/environments/blue/modules/{proposals,compute}/** stayed in the delivery repository, and with them the ApplicationSet each tree carries, which kept stamping Argo Applications whose every pod the cell (once correct) refuses;
  • and once the cell file was right, every publish refused with --allow-cell-removal (proposals) — asking the operator to type back the name of a module the workspace no longer composes.

Change

Render derives the cell file from the composition and prunes what it no longer declares. Before deriving the file, RenderCell deletes every render record under deployments/modules/ whose module is not in workspace.Modules, for any environment (pruneUndeclaredRecords), reports them (CellResult.Removed), and the render prints one line per record: removed deployments/modules/proposals: the composition no longer declares it. Every render that re-derives the cell file prunes — a single module's and --all alike — because that is where the claim about the cell is made. An environment with no host: has no cell file and nothing is pruned.

A removal the composition explains needs no naming — from a checkout that has seen the base's composition. A checkout that never had a module and one that dropped it derive the same cell file, so holdCellToBase judges with a compositionClaim: the composition's module set, and whether the checkout's HEAD descends from the revision the base's cell file came from — the delivery base commit itself whenever the workspace repository and the delivery repository are one (the workspace holds the base commit, or the delivery clone holds the workspace revision the file names as source.revision: platform-obin is its own delivery repository), because that source revision is the render's parent and a modules: edit rides in the render's own commit, so an unfetched checkout at the parent fails closed; the source.revision itself only when the repositories are separate — and then the workspace's own configuration as committed at that revision (git show <rev>:workspace.codefly.yaml, modules:/solutions:, or the workspace itself when flat) must have declared the module (declaredAtRevision), because a render may run on an uncommitted modules: edit and a module of an imported workspace is another repository's history. The claim is therefore "the checkout descends from a composition that named M and no longer names it", in every topology. From such a checkout a removal whose module is no longer declared is let through without --allow-cell-removal and reported separately (PublishPlan.UndeclaredCellRemovals, printed as The cell file removes <what>: the composition no longer declares module <m>). From any other checkout — a branch behind the base, a base file naming no source — the hold refuses as before, says which revision the checkout lacks, and naming the module confirms the removal as before (without pruning the tree). The flag keeps its contract for everything else — a module still composed whose render is missing from the checkout, a dependency dropped, a service withdrawn.

Publish removes the delivered tree. A forward publication to a hosted environment, under an advanced claim, removes from the promotion clone every <gitops path>/modules/<name> that carries a render record and whose module the composition no longer declares (removeUndeclaredDeliveries), stages those paths with everything else it writes, and shows them in the plan (PublishPlan.RemovedModules, printed as Removed <path> from the delivery: the composition no longer declares it). The branch guard owns such trees by rule (compositionClaim.owns), not by what the clone still holds: the branch a publish continues, or the stale tip it replaces, carries this module's own earlier removal, and the base may since have taken it. A rollback removes none; a directory under the modules path with no render record is not a delivery and is left alone.

Flag help, docs/commands.md and the generated docs/cli-reference.md move with it.

Classification

Fix, at the place that owns the behaviour: the cell file is the render's claim about the composition, so the render is what derives it from the composition and discards what contradicts it; the publication is what carries the composition's claim into the delivery repository, so it is what removes the delivered tree. No new mechanism: the hold (holdCellToBase), the owned/published path sets (publicationCellPaths), the staging (stagePublication) and the plan printer are reused; the prune is a branch in the one derivation loop.

Overlap

#1290 (the admission-check work) landed while this was in review, with refuseUncomposedRender: the derivation refuses a tree of a module the workspace does not compose and asks for its removal. That check is untouched. This PR's prune now runs in RenderCell before deriveCell, so a render satisfies the hold by doing what it asks — removing the tree — rather than bypassing it; deriveCell still refuses such a tree for any caller that does not prune first. #1290's test TestCellFileRefusesATreeOfAModuleTheWorkspaceDoesNotCompose had to move with the contract: it asserts the refusal on deriveCell and the prune on RenderCell (same fixture, same message). The one lint finding left on the touched packages (gocritic rangeValCopy in environment_declaration.go:36) is #1290's and pre-existing on main.

Tests

Three new tests, each failing with the behaviour toggled off and passing with it:

  • TestRenderCellPrunesTheRecordOfAModuleTheCompositionNoLongerDeclares (pkg/gitops/cell_test.go): records for proposals (this environment) and compute (another) render to a cell file with neither, both records deleted and reported, the composed modules' records intact.
  • TestARemovalTheCompositionExplainsNeedsNoNaming (pkg/gitops/cell_removals_test.go): a third module leaves modules:; the render prunes its record, and the hold passes with no flag, reporting the namespace and the consumer edge as removals the composition explains. The existing test still requires the flag for a composed module whose render is missing.
  • TestPublishRemovesADeliveredModuleTheCompositionNoLongerDeclares (pkg/gitops/publish_cell_test.go): a delivered legacy tree on the base is removed by a forward publish from a checkout at the base file's source revision — in the plan's RemovedModules, its files among Changed, absent from the published commit — while a directory with no render record under the modules path is left alone.
  • TestAPromotionOwnsTheRemovalItsOwnBranchCarries: a second publish on the module's own promotion branch, whose earlier commit removed legacy, is not refused as an unrelated change and removes nothing again.
  • TestAStaleCheckoutStillNeedsTheFlagToWithdrawAModule: a base file rendered from a revision the checkout does not contain, naming a module the checkout never had, is refused with --allow-cell-removal (legacy) and the reason; named, it passes without pruning the delivered tree.
  • TestACompositionThatNeverDeclaredTheModuleDoesNotRemoveIt: separate delivery repository, base file naming the checkout's own HEAD as source (a render on an uncommitted edit that named legacy), the committed composition never declared legacy → refused, naming the revision whose configuration did not declare it; named, passes without pruning.
  • TestAnUnfetchedCheckoutOfTheDeliveryRepositoryStillNeedsTheFlag: the delivery remote holds the workspace's history (one repository); a base file naming the checkout's own HEAD as its source, with a module the checkout never had, is refused and, named, passes without pruning.
  • TestTheCompositionClaimIsAdvancedOnlyFromACheckoutThatSawTheBase (pkg/gitops/cell_removals_test.go): the ancestry judgement — the base commit whenever the repositories are one (the workspace holds it, or the delivery clone holds the source revision: a checkout at the render's parent is not advanced, fetched or not), the named source revision for separate repositories, nothing for a base with no file or no source, an orphan branch never.

go test ./pkg/gitops/... ./cmd/deploy/... ./cmd, golangci-lint run ./pkg/gitops/... ./cmd/deploy/... (0 issues), gofmt -l clean, TestCLIReferenceIsCurrent regenerated.

Review

An independent fresh-context review of ef45a13f found two blockers, fixed in the second commit: (1) the branch guard refused a publish continuing or replacing the module's own promotion branch that had removed a tree, because ownership was derived from the clone's working tree — now a rule (compositionClaim.owns); (2) a stale checkout that never had a module could withdraw it with no flag — now the hold and the pruning require the checkout to descend from the base file's revision. Its re-review of e8a82e4a found one more: the source-revision fallback was fooled by an unfetched checkout of the one repository, at the render's parent — fixed in the third commit by letting the delivery clone's holding that revision tell one repository from two, in which case only the base commit counts. Its round 3 (cd3c9b2e) found the same hole for two repositories through an uncommitted modules: edit at render time — fixed in the fifth commit by requiring the judged revision's own committed configuration to have declared the module.

Not verified

No real Blue render or publish was run against platform-obin; the fixture trees stand in for it. A module declared only through an imported workspace (workspaces:) is never explained by the composition — its removal keeps needing the flag — which is conservative, not wrong. I would file (not opened, per instruction): the render could record the workspace commit that carries it rather than its parent, which would make the two-repository judgement as exact as the one-repository one without reading the committed configuration — a cell-file schema change its consumer (infra-base's pin) would read.

🤖 Generated with Claude Code

@antoinetoussaint-byte
antoinetoussaint-byte force-pushed the fix/render-prunes-undeclared-modules branch from e8a82e4 to cd3c9b2 Compare November 4, 2026 05:10
antoinetoussaint-byte and others added 5 commits November 4, 2026 00:17
The cell file was derived from the directory listing of
deployments/modules/, not from the composition: a module removed from
`modules:` left its render record behind and kept its namespace,
workloads and consumer edges in every cell file derived after it, its
delivered tree stayed in the delivery repository with the ApplicationSet
that kept stamping Applications the cell refused, and once the file was
right every publish refused until someone named the module back with
--allow-cell-removal.

The render now derives the cell file from the composition, deletes the
records of modules it no longer declares once the file is written, and
prints each removal. A removal the composition explains passes the hold
without the flag and is reported as such; the flag keeps confirming
every other removal. A forward publication to a hosted environment
removes the delivered tree of such a module with everything else it
writes and shows it in the plan; a rollback removes none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…aw the base

Two findings of the independent review of the first commit. A publish
continuing or replacing the module's own promotion branch refused the
removal that branch already carried as an unrelated change, because the
removed trees were owned only when the clone still held them; the branch
guard now owns the delivered tree of any module the composition does not
declare by rule. And a stale checkout that never had a module derived the
same cell file as one that dropped it, so it could withdraw a live module
with no flag; the hold and the pruning now require the checkout to
descend from the revision the base's cell file came from — the delivery
base commit itself when the workspace repository holds it, else the
source revision the file names — and say which revision it lacks when it
does not, where naming the module confirms the removal as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… advanced

The base's cell file names the render's parent as its source, and a
`modules:` edit rides in the render's own commit, so a checkout of the
one repository that had not fetched the base commit sat at that parent,
descended from it, and was judged to have seen a composition it never
had. The delivery clone holding the workspace revision the file names is
what tells one repository from two: then only the base commit counts,
and a checkout that lacks it is held as before, told which commit it
lacks. The source revision is judged only for separate repositories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#1290 made the derivation refuse a tree of a module the workspace does not
compose and ask for its removal. The prune now runs before the derivation,
so a render satisfies that hold by removing the tree rather than skipping
it; the hold stays for any derivation nothing pruned first, and its test
asserts it there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the module

With a separate delivery repository the base's cell file names the
render's parent, and a render may run on an uncommitted `modules:` edit,
so a checkout at that parent descended from it without ever having
composed the module the render named, and could withdraw it on the
composition's word. The judged revision's own committed configuration
must now have declared the module; a composition rendered before it was
committed, or a module of an imported workspace, vouches for nothing and
the removal stays the flag's to confirm, with the refusal saying so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte force-pushed the fix/render-prunes-undeclared-modules branch from cd3c9b2 to 2e96908 Compare November 4, 2026 05:17
The revision is read from the delivery branch's cell file and spliced
into git arguments; anything but a full hash is treated as no source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 9afc06e Nov 4, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant