Skip to content

fix(gitops): deliver from codefly's ghcr.io image and restart a squash-merged promotion branch - #1142

Merged
antoinetoussaint-byte merged 1 commit into
mainfrom
fix/publish-ghcr-carrier-and-stale-promotion
Oct 24, 2026
Merged

antoinetoussaint-byte merged 1 commit into
mainfrom
fix/publish-ghcr-carrier-and-stale-promotion

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

What broke

  1. Docker Hub in every delivery Job. pkg/gitops/delivery_jobs.go pinned docker.io/curlimages/curl@sha256:d94d07… as the delivery carrier, so every module's delivery Job — and every cell file's declared delivery carrier — pulled from Docker Hub (anonymous pulls rate-limited, and a cell's nodes are not promised a route to it).
  2. Publish stacked on a squash-merged promotion branch. clonePromotionRepository continued an existing origin/codefly/promote-<module>-<env> even when the base had moved past it. After a promotion PR is squash-merged and its branch kept, the next publish forked from the stale base, so every file conflicted add/add against main — seen on obin-ai/platform-obin feat: serve typed code-unit discovery #251/Explicit runtime formula must outrank generic source fallback #252.

What changed

  • Delivery carrier: deliveryImage is now ghcr.io/codefly-dev/codefly@sha256:8fcaaea34622eb1743a5b755c7c6540a0be245cb37c8b2188732f45c8207a833, the multi-arch index of codefly's base companion tag 0.0.4. That is the newest published tag; the package has no other tags apart from its cosign .sig. The comment says it is codefly-owned, on ghcr.io, and never Docker Hub. Because the cell file declares the same image (cellDelivery splits it), the cell's carrier repository is now ghcr.io/codefly-dev/codefly.
  • Stale promotion branch (PR path, not self-promotion): an existing promotion branch is continued only if it contains the current base (gitIsAncestor(base, branch)). Otherwise the publish starts from the base and replaces the branch with git push --force-with-lease=refs/heads/<branch>:<old tip>. The replaced tip is recorded as PublishPlan.ReplacesBranchRevision (replacesBranchRevision,omitempty). It is part of publishPlanID, so a branch that moves after review refuses the publish. printPublishPlan warns with the tip being replaced. Before anything is replaced, refuseUnrelatedPromotionChanges still runs against the stale tip, so a branch carrying a change outside the module path is refused rather than overwritten. A branch that already contains the base keeps the existing retry/continue behaviour (same signed commit, PR and receipt).
  • refuseUnrelatedPromotionChanges now takes the revisions to hold as a variadic list and skips empty ones. This keeps preparePublish under the gocyclo limit and avoids a second loop.

Behaviour change to note: a PR-path publish whose branch is already merged (contained in the base) and whose render equals the base now reports promotion has no changes. Before, it re-offered the already-merged commit, which GitHub would refuse as a PR with no commits. TestRollbackRefusesUnresolvedConsumedContractUnlessAllowed relied on that: it rolled back to the tree main already delivered. Its fixture now merges a second promotion before rolling back to the first, so the rollback has a tree to re-promote. Its assertions are unchanged, and the adjusted test also passes against the old code.

Classification

Fix, at the owner. The delivery image and the promotion-branch logic both live in the CLI's GitOps publish path, so nothing here works around another repo.

Reuse

  • Reused: codefly's own base companion image (ghcr.io/codefly-dev/codefly, already published and signed), and the existing machinery for git leases and ancestry (gitIsAncestor), unrelated-change refusal, plan-ID binding and snapshot lineage.
  • Not added: a new delivery image or build, a separate replacement mechanism, or any change to the self-promotion (local k3d) path, which already restarts from the base by fast-forward and never forces.

Tests

  • delivery_jobs_test.go: the Job's image must start with ghcr.io/codefly-dev/ and carry @sha256:.
  • delivery_carrier_contract_test.go TestTheCellDeclaresCodeflysOwnDeliveryImage: the cell's carrier image repository is ghcr.io/codefly-dev/codefly, its digest is a sha256, and repository@digest equals the Job's image.
  • publish_test.go TestPublishRestartsAPromotionBranchItsSquashMergeLeftBehind: squash-merge with the branch kept. The plan's ReplacesBranchRevision equals the old tip. After publish, main is an ancestor of the branch, the branch's diff against main touches only the module path, git merge-tree --write-tree main <branch> succeeds, and snapshot lineage holds. With the fix disabled it fails on both the plan assertion and the merge-base --is-ancestor main check.
  • publish_test.go TestPublishRefusesToReplaceAStaleBranchCarryingUnrelatedChanges: main moved and the stale branch carries unrelated.txt. The plan is refused with unrelated change and the remote branch stays at its tip.
  • Unchanged and green: TestPublishRetriesPRAndReceiptForExistingSignedBranchCommit, TestOpenOrUpdatePullRequest*, TestPublishPreservesSnapshotLineageAfterSquashAndBranchDeletion, TestPublishRejectsUnrelatedExistingPromotionChanges.
  • Ran go test ./pkg/gitops/... ./cmd/deploy/... -count=1 (all ok) and golangci-lint run ./... v2.14.0 (0 issues).

Image proof: docker buildx imagetools inspect ghcr.io/codefly-dev/codefly:0.0.4 gives index sha256:8fcaaea3…a833 (linux/amd64 and linux/arm64). I ran the exact deliveryScript in that image, pinned by digest, as --user 65532:65532 --read-only --cap-drop ALL --security-opt no-new-privileges with read-only documents and token mounts:

  • A 200 was delivered with its receipt and the bearer token read from the file.
  • A 422 was refused, and the Job exited 1 at the end after offering every document.
  • With the host unreachable, the script retried in rounds and gave up inside its budget.
  • sh, curl, date, basename, cat, tail, sed, printf (a shell builtin) and sleep are all present.

Docs

  • docs/module-manifests.md: the delivery Job section names the ghcr.io image and the cell re-render consequence. The local-promotion section gains the hosted counterpart of the rule: continue only a branch that contains the base, otherwise replace it under a lease, and refuse rather than overwrite unrelated changes.
  • docs/commands.md: the publish description states the same rule.

Rollout consequence

Changing the carrier image changes every cell file, because the cell declares the delivery carrier's image for admission. Each environment's cell must be re-rendered, and that cell file must be published with or before the first delivery Job that runs the new image. Otherwise admission refuses the new Job's pod.

Not verified

🤖 Generated with Claude Code

…h-merged promotion branch

Two defects in `codefly deploy gitops publish`:

- Every delivery Job (and every cell file's delivery carrier) pinned
  docker.io/curlimages/curl. It now runs codefly's own base companion,
  ghcr.io/codefly-dev/codefly pinned by the digest of its 0.0.4 multi-arch
  index, which carries every command the delivery script calls. Nothing in a
  delivery is pulled from Docker Hub.

- A reviewed publish continued an existing promotion branch even when the
  base had moved past it (its pull request squash-merged, the branch kept),
  so the new commit forked from a stale base and conflicted add/add on every
  file. It now continues a branch only when the branch contains the base;
  otherwise it starts from the base and replaces the branch with a push
  leased on the stale tip, recorded in the plan as replacesBranchRevision
  (part of the plan ID) and printed by `plan`. A stale branch carrying a
  change outside the module path is still refused, never overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 24, 2026
Merged via the queue into main with commit 455c9fe Oct 24, 2026
14 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