Skip to content

feat(deploy): publish prebuilt images as a third application source - #103

Merged
wellCh4n merged 15 commits into
mainfrom
feat/image-source
Sep 8, 2026
Merged

wellCh4n merged 15 commits into
mainfrom
feat/image-source

Conversation

@wellCh4n

@wellCh4n wellCh4n commented Sep 8, 2026

Copy link
Copy Markdown
Owner

An application can now be sourced from an image instead of a Git repository or a ZIP. The build config's existing repository field carries the image name (stored as ImageSourceConfig, validated to carry no tag or digest) and each publish names the tag, mirroring how a Git publish names the branch. Nothing is built: the pipeline is born with artifact = name:tag, the same shape the build path produces, so the StatefulSet processor, rollback, notifications and namespace migration need no special case.

The deploy phase that a manual deploy, a rollback and now an image publish all run is extracted into ArtifactDeployRunner; IMMEDIATE goes INITIALIZED -> DEPLOYING -> ROLLING_OUT through it and MANUAL parks in BUILD_SUCCEEDED, the one new transition the state machine allows, for the ordinary deploy call. Build-only settings are dropped when an application switches to IMAGE rather than kept invisibly, and the IDE is refused for it as for ZIP since there is nothing to clone.

The editor shows an Image source tab with only the image field; the publish page renders the tag as a prefixed input with the image name fixed in front of it and offers the last published tag back; the pipeline page and list mark an image publish and skip the step bar and log streams it does not have. The OpenAPI docs describe the new strategy in all four locales.

First version accepts only images pullable without credentials, does not check the registry (a wrong tag fails the rollout as ImagePullBackOff), and treats re-publishing the same tag as a no-op rollout, as documented in AGENTS.md.

wellCh4n and others added 15 commits September 8, 2026 16:20
An application can now be sourced from an image instead of a Git
repository or a ZIP. The build config's existing `repository` field
carries the image name (stored as `ImageSourceConfig`, validated to
carry no tag or digest) and each publish names the tag, mirroring how a
Git publish names the branch. Nothing is built: the pipeline is born
with `artifact = name:tag`, the same shape the build path produces, so
the StatefulSet processor, rollback, notifications and namespace
migration need no special case.

The deploy phase that a manual deploy, a rollback and now an image
publish all run is extracted into `ArtifactDeployRunner`; `IMMEDIATE`
goes INITIALIZED -> DEPLOYING -> ROLLING_OUT through it and `MANUAL`
parks in BUILD_SUCCEEDED, the one new transition the state machine
allows, for the ordinary deploy call. Build-only settings are dropped
when an application switches to IMAGE rather than kept invisibly, and
the IDE is refused for it as for ZIP since there is nothing to clone.

The editor shows an Image source tab with only the image field; the
publish page renders the tag as a prefixed input with the image name
fixed in front of it and offers the last published tag back; the
pipeline page and list mark an image publish and skip the step bar and
log streams it does not have. The OpenAPI docs describe the new strategy
in all four locales.

First version accepts only images pullable without credentials, does
not check the registry (a wrong tag fails the rollout as
ImagePullBackOff), and treats re-publishing the same tag as a no-op
rollout, as documented in AGENTS.md.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Saving an IMAGE build config failed in production with "Data truncated
for column 'source_type'" and rolled the whole save back. Databases that
predate Flyway were created by Hibernate's DDL, and Hibernate 6 maps an
@Enumerated(STRING) field on MySQL to a native ENUM column frozen at the
constants of the day, so source_type and publish_type were
ENUM('GIT','ZIP') even though the V1 baseline documents them as
varchar(255).

V23 turns every such column (source_type, publish_type, cert_mode,
provider, role) into varchar(255). MODIFY keeps the stored labels and is
a no-op on a fresh database that already has varchar, so adding an enum
constant never needs a migration again. Verified against MySQL 8.4: the
truncation reproduces on the ENUM column, the migration converts it with
values intact, runs cleanly a second time, and IMAGE writes succeed.

Also adds the IMAGE variant to the persistence mapper round-trip test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
An image publish is an ordinary release, not a rollback, so it gets no
badge on the pipeline detail header and no icon in the list's deploy
mode column. The detail page still hides the step bar and build log,
since there is no build job to watch. The unused pipelines.col.imageTag
key is dropped from all four locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
Two tabs of the application editor saved one form with two sequential
requests, i.e. two transactions. The build tab sent PUT build/config
without environmentConfigs, which nulled every build command, and then
PUT environments/build/configs to write them back; the basic-info tab
sent PUT /{name} and then PUT /{name}/environments. In both, a failed
second call left a half-saved record behind a generic error toast.

The build tab now carries environmentConfigs inside PUT build/config,
which the backend already accepted, and the page reads them from the
same GET. The profile update takes an optional environments list and
binds them in the same transaction; leaving the field out keeps the
bindings, so the CLI's app update cannot wipe them, and
PUT /{name}/environments stays for changing bindings alone.

The GET/PUT environments/build/configs and environments/runtime-specs
endpoints, their service and domain methods, docs entries and route
manifest rows are removed; the runtime pair had no caller at all. The
integration suite reads build commands back from the build config and
gains a test for the profile update's three binding cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
The tag input starts as latest and is replaced by the last published tag
when there is one, the same way the branch defaults to main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
PUT build/config wrote environmentConfigs verbatim, so an OpenAPI caller
that does not send the list, which is what the CLI's app build set has
always done, cleared every per-environment build command on each call.
An absent list now leaves the stored commands alone, the same rule the
profile update applies to its environment bindings; the editor always
sends the full list and is unaffected. Covered by a domain test and an
integration test, and stated in the endpoint docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
The deploy CLI could not reach the IMAGE source at all: app build set
only accepted git and zip, and deploy had no image subcommand. It now
takes --source image with the image name in --repository, and
deploy image -n <ns> <app> --env <env> --tag <tag> (default latest)
triggers a publish of that tag.

app build set also gains --build-command ENV=COMMAND (repeatable) so the
per-environment build commands can be set from the CLI; without the flag
the stored commands are kept, and an empty string clears them. app build
get prints them. SKILL.md documents both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
The deploy CLI has no app update; the profile PUT's omitted-means-unchanged
rule protects any OpenAPI caller built before the field existed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
The pod log route moved to GET .../pods/{pod}/log when pod logs went
over SSE, but routes.json was edited by hand since then and never
regenerated, so the CI freshness check failed. This is the extractor's
output; the SSE stream test already exercises the route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
Pod logs have been served over SSE since the pod-log refactor, but the
two stream tests still opened a WebSocket to the endpoint and failed on
the handshake, which also left the route unreached for the coverage
check. They now read the stream the way the browser does: the first
stamped batch of a running pod (whose stream never ends by itself), and
error-then-end for a pod that does not exist. The ping/pong assertion
went with the socket; SSE keepalives are comment lines the client
discards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
A scenario that skips when its own deploy ends as ERROR leaves the run
without a sound, and the endpoints only it reaches are then reported by
the coverage test as covered by no scenario at all. That is what the
last run showed: six ApplicationController reads blamed on the coverage
gate, twenty minutes after the deploy that actually broke, with advice
("add them to a scenario") that would have been wrong to follow.

The deploy these scenarios run is their own setup, not a capability the
stack may lack, so require_successful_deploy fails with the pipeline's
status and message. Genuine skips are untouched: no cluster, no Traefik
CRDs, object storage disabled, and the two races that skip when a
pipeline finishes before it can be stopped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
A failed build stores only "look at the pipeline log", and by the time
anyone reads CI the cluster has been torn down, so the reason is gone.
That is exactly what the last run left behind: a build that failed 60
seconds in, with nothing anywhere saying which step exited or why.

require_successful_deploy now pulls the tail of every build step's log
through the SSE endpoints and prints it with the failure. Best effort
throughout — a build that failed before its pod existed has no log, and
that must not replace the real failure with an error from the helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
A full run is half an hour of real builds, so carrying on after the
first failure spends twenty-five minutes on a result nobody will act on
until that failure is fixed. The last two runs both did exactly that.

PYTEST_ARGS gains -x. Anything after `--` lands later on the command
line and wins, so `-- --maxfail=0` still runs the suite out, which the
usage text now says. The failure block says the run stopped early, since
the counts above it are of what ran rather than of the suite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
test_operations imports from test_deploy inside each function, and the
helper was added to neither, so the restart and namespace-migration
scenarios died with NameError the moment their deploy finished. Compiling
the file does not catch it, which is why the last run did.

Verified by walking the AST of every test module and checking each call
site has the name bound in an enclosing scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
…sitory

GIT and IMAGE shared one `repository` field, so switching the source
carried the value across: pick Image on an application configured from
Git and the image box was pre-filled with a Git URL, ready to be saved
as an image name.

They are now two fields. `ImageSourceConfig.image` replaces its
`repository`, the build config DTO carries both, and
`ApplicationBuildConfig` exposes `repository()` and `image()` — each
answering for its own source and null for the others, so a caller after
the Git URL and a caller after the image name can no longer be served
the wrong one. The policy validates only the field the chosen source
uses, and the editor, publish page, CLI (`--image`), OpenAPI docs in all
four locales and the integration suite follow.

Breaking for anything that sent an image as `repository`: the build
config PUT now takes `image`, and rows written before this read back with
no image, which is a re-save in the editor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ok88Qo6rYYyBExFj1qgXf
@wellCh4n
wellCh4n merged commit bf824a0 into main Sep 8, 2026
1 check 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