Clarification + concrete mechanism for ADR-012 (Project Delivery Automation). Pulls together two prior attempts (modules/application RustWebapp + the per-component harmony-fleet-release binary) and proposes a unified shape: release is a Score driven by Topology capabilities (ContainerBuilder, OciRegistry, HelmRegistry, ContinuousDelivery), composed alongside DeployScore / MonitoringScore into the opinionated pipeline ADR-012 specified. Kept in drafts as 012-1 rather than a fresh ADR-025 — this implements ADR-012's intent, doesn't compete with it. Open questions deliberately left open for further 012-N follow-ups.
17 KiB
ADR-012 follow-up: Release Architecture Mechanism (Capability-Driven Build, Package, Push)
Initial Author: Jean-Gabriel Gill-Couture (drafted with Claude)
Initial Date: 2026-05-27
Last Updated Date: 2026-05-27
Status
Draft. Clarification + mechanism for ADR-012, not a separate
decision. ADR-012 (Project Delivery Automation, 2025-06-04)
locked the intent: application-level Scores (LAMPScore-style)
drive an opinionated pipeline — empty-check → package&publish →
deploy staging → sanity-check → deploy production → sanity-check —
with CD tools (Argo / Flux) activated by default and called via
API; the same harmony binary runs the project's module locally
and in CI.
This document supplements that intent with the concrete mechanism:
how steps 2, 3, 5 (package&publish, deploy staging, deploy
production) actually compile and execute against today's
Score-Topology-Capability primitives, and what to delete from the
two interim attempts (modules/application + the per-component
harmony-fleet-release binary) when the mechanism lands.
Companion references:
- ADR-023 (Deploy Architecture, Accepted) — the
DeployScoreproposed here plugs into theharmony_cli::runflow and*-deploycrate pattern ADR-023 mandates; the smoke-test contract (ADR-023 principle 4) is how steps 4 and 6 of ADR-012 block. - ADR-024 draft (Fleet Platform Capability Decomposition) — same decomposition shape, applied to release: framework-level capability traits, not per-app methods.
- ADR-003 — capability traits represent industry concepts, not tools. Every new capability here passes the swap-out test.
- ADR-018 — template hydration.
ChartSource::Builderkeeps the chart fully typed up to package time.
Open questions remain in the Open questions section below; they are deliberately left open for follow-up clarifications to bolt on the same way this one bolts on to ADR-012.
Context
ADR-012's intent never got a concrete mechanism beyond LAMPScore
and the (later-abandoned) modules/application parallel hierarchy.
As a result, today every app harmony hosts re-implements its own
release pipeline:
- A ~270-line per-component release binary (
fleet_release.rs). - A per-component
release.shwrapper script. - A per-component
.gitea/workflows/<app>-<component>.yaml. - Hand-written chart hydration (
fleet/harmony-fleet-deploy/src/operator/chart.rs).
PR review (#301) flagged this directly: "we should not have to rebuild a new cli for every component of every app using harmony" and "the burden of designing the process and cli should not be on the user. Fleet is the equivalent of a user." The framework owns deploy (ADR-023); release is the missing half of ADR-012.
A naive fix — "add a define_release! macro so each component is
one declarative invocation" — solves the duplication but leaves the
mechanism wrong. Building, packaging, and pushing are not app
concerns. They are capabilities that an environment provides (a
docker daemon, an OCI registry, a CI runner), the same way DNS,
LoadBalancer, or PostgreSQL are.
This ADR pulls release into the Score-Topology-Capability pattern that already governs deploy.
Two prior attempts and what each got right
Attempt 1 — modules/application (the RustWebapp example, ApplicationScore, ApplicationFeature)
examples/rust/src/main.rs shows what good DX looks like:
let application = Arc::new(RustWebapp {
name: "harmony-example-rust-webapp".to_string(),
project_root: PathBuf::from("./webapp"),
framework: Some(RustWebFramework::Leptos),
service_port: 3000,
..
});
let app = ApplicationScore {
features: vec![
Box::new(PackagingDeployment { application: application.clone() }),
Box::new(Monitoring { application: application.clone(), alert_receiver: vec![...] }),
],
application,
};
What it got right:
| Property | Why it's right |
|---|---|
| Declarative app spec at the call site | One struct, ~15 lines, describes the whole thing |
Per-app type implements capability traits (OCICompliant, HelmPackage, Webapp) |
Compiler rejects nonsensical combos — features state their bounds (impl<A: OCICompliant + HelmPackage> ApplicationFeature for PackagingDeployment<A>) |
| Features compose orthogonally | vec![PackagingDeployment, Monitoring, …] extensible without touching the app |
| One feature does one thing | Small, testable, replaceable |
What it got wrong:
| Anti-pattern | Symptom |
|---|---|
| Parallel hierarchy | Application / ApplicationFeature / ApplicationInterpret / ApplicationScore live alongside Score / Interpret — two ways to express the same thing |
| App-owned capabilities | OCICompliant::build_push_oci_image() is a method on the app — the app does the build. Should be: topology provides a builder, score uses it |
MultiTargetTopology + DeploymentTarget::LocalDev / Production |
Inline match topology.current_target() { LocalDev => …, _ => Argo… } in PackagingDeployment::ensure_installed. The author's own comment: "It still does not feel right though." |
| ArgoCD hardcoded as the production mechanism | Should be a ContinuousDelivery capability; OPNsense vs CoreDNS distinction (ADR-003) applies — Argo today, Flux or something else tomorrow |
Attempt 2 — harmony-fleet-release binary + release.sh (the current branch)
After abandoning modules/application's parallel hierarchy, fleet
went the other direction: hand-rolled per-component binaries, no
framework-level abstraction. Solved the parallel-hierarchy problem
by removing the hierarchy entirely; created a 270-line per-component
duplication problem in its place.
Net diagnosis: attempt 1 had the right shape (declarative app + composable features + capability bounds), wrong mechanism (parallel layer, app-side capabilities). Attempt 2 had the right mechanism (use the existing Score/Topology primitives) but no abstraction. The fix is to keep attempt 1's shape and express it in attempt 2's mechanism.
Mechanism (proposed)
A release is a Score driven by Topology capabilities, exactly like
a deploy. No parallel hierarchy. No per-component CLI binaries. No
app-side build/push methods. ADR-012's opinionated lifecycle pipeline
becomes a composed Vec<Box<dyn Score<T>>> — each pipeline step is
one Score, harmony executes them in order, each blocks on the
smoke-test contract (ADR-023, step 4).
Capabilities (topology-side)
New capability traits, alongside DnsServer, HelmCommand, etc.:
pub trait ContainerBuilder {
async fn build(&self, ctx: &BuildContext, image_ref: &str) -> Result<(), Error>;
}
pub trait OciRegistry {
async fn push_image(&self, image_ref: &str) -> Result<(), Error>;
fn registry_url(&self) -> &str;
}
pub trait HelmRegistry {
async fn push_chart(&self, tgz: &Path, project: &str) -> Result<(), Error>;
}
pub trait ContinuousDelivery {
async fn sync_to(&self, chart_ref: &ChartRef, target: &DeployTarget) -> Result<(), Error>;
}
Each capability is an industry concept (per ADR-003 rule). Adapters:
| Capability | LocalDev provider | CI provider | Production provider |
|---|---|---|---|
ContainerBuilder |
local docker daemon | gitea runner docker | remote buildkit |
OciRegistry |
local registry, or noop (k3d image-import) | hub.nationtech.io | hub.nationtech.io |
HelmRegistry |
noop (local helm) | hub.nationtech.io | hub.nationtech.io |
ContinuousDelivery |
direct helm install | direct helm install | ArgoCD ArgoHelmScore |
K8sAnywhereTopology composes these capabilities the way it already
composes K8sclient + HelmCommand + Ingress + …. A CI-runner
topology variant composes the same caps with CI-flavored adapters.
Application spec (data only)
pub struct AppSpec {
pub name: &'static str,
pub project_root: PathBuf,
pub image: ImageSource, // Dockerfile path + build args, OR a `Buildable` impl that emits one
pub chart: ChartSource, // PathBuf to a directory, OR a `ChartBuilder` closure / impl returning HelmChart
}
No methods that do work. The spec is pure data. Methods (build,
push, package) live on the topology capabilities. This is the
shift that fixes attempt 1: RustWebapp no longer implements
OCICompliant::build_push_oci_image(); instead, AppSpec describes
what to build, and ContainerBuilder (on the topology) does it.
Scores
pub struct ReleaseScore { app: Arc<AppSpec>, version: String }
pub struct DeployScore { app: Arc<AppSpec>, version: String, target: DeployTarget }
impl<T: Topology + ContainerBuilder + OciRegistry + HelmRegistry> Score<T> for ReleaseScore { ... }
impl<T: Topology + ContinuousDelivery> Score<T> for DeployScore { ... }
The trait bounds are the spec of what a topology must provide.
Compile-time guarantee: you cannot construct ReleaseScore against
a topology that can't release.
The opinionated pipeline (ADR-012 realized)
ADR-012's pipeline maps directly onto a Score sequence:
| ADR-012 step | Mechanism |
|---|---|
| 1. Empty check | Out of scope for harmony (matches ADR-012). Project's own CI step runs ahead of harmony-<app> invocation. |
| 2. Package & publish | ReleaseScore — builds image, hydrates chart, pushes both. |
| 3. Deploy to staging | DeployScore { target: DeployTarget::Staging } — talks to the topology's ContinuousDelivery (Argo by default per ADR-012). |
| 4. Sanity check staging | Smoke-test companion on the DeployScore (ADR-023 principle 4). |
| 5. Deploy to production | Same DeployScore with DeployTarget::Production. CI gates approval; harmony just runs the score. |
| 6. Sanity check production | Same smoke-test companion against production. |
Call site (after the migration)
let app = Arc::new(AppSpec {
name: "harmony-fleet-operator",
project_root: ".".into(),
image: ImageSource::Dockerfile("fleet/harmony-fleet-operator/Dockerfile".into()),
chart: ChartSource::Builder(Box::new(operator::chart::build)),
});
let scores: Vec<Box<dyn Score<_>>> = vec![
Box::new(ReleaseScore { app: app.clone(), version: from_tag(ref_name)? }),
Box::new(DeployScore { app: app.clone(), version, target: DeployTarget::Staging }),
Box::new(DeployScore { app: app.clone(), version, target: DeployTarget::Production }),
];
harmony_cli::run(Inventory::autoload(), CITopology::from_env(), scores, None).await?
That's the user-facing surface for any app. It matches ADR-012's
"run the same command anywhere" promise — same binary, local laptop
or CI runner; topology adapters differ, Scores don't. No per-component
release binary. No release.sh per component. Adding a component is
one new AppSpec value.
CI integration
Workflow yaml shrinks to:
- name: Release
run: |
harmony-fleet --release --from-tag "$GITHUB_REF_NAME"
The --from-tag flag is implemented inside harmony_release (Rust,
not bash). It parses <app>-<component>-v<semver> against the
registered AppSpecs and constructs ReleaseScore { app, version }
for the matching one. Tag malformed → fail at CI start, not three
minutes into the docker build.
.gitea/scripts/resolve-release-version.sh (just shipped) is the
interim form; the script disappears once --from-tag lands.
What replaces the modules/application layer
Application / ApplicationFeature / ApplicationInterpret /
ApplicationScore are deleted. Migration path:
| Old | New |
|---|---|
RustWebapp (struct implementing capabilities) |
AppSpec { name, project_root, image: ImageSource::Buildable(Box::new(RustLeptosBuilder)), chart: … } |
OCICompliant::build_push_oci_image |
ContainerBuilder::build on the topology (Score calls it) |
HelmPackage::build_push_helm_package |
HelmRegistry::push_chart on the topology (Score calls it) |
PackagingDeployment (ApplicationFeature) |
ReleaseScore + DeployScore (regular Scores) |
Monitoring (ApplicationFeature) |
MonitoringScore (regular Score) |
ApplicationScore wrapping features |
Vec<Box<dyn Score<T>>> passed to harmony_cli::run |
The RustWebapp Leptos Dockerfile generator survives — it becomes a
Buildable impl that emits a Dockerfile for ImageSource::Buildable
to use. Same code, different home.
Alternatives considered
Macro per component — define_release!(Component::Operator { … })
Solves the duplication. Still leaves build/push as app-side work, still gives every app its own CLI surface. Doesn't address the capability gap. Rejected.
Keep modules/application as-is, add a release feature
Adds a third release variant to the existing parallel hierarchy. Doubles down on the wrong mechanism. Rejected.
Plugin-discovery harmony top-level binary (ADR-023 "tomorrow C")
Orthogonal to this clarification. Plugin discovery is how the
binaries are invoked; this is what the binaries do. Compatible:
each app ships a harmony-<app> plugin binary that registers its
AppSpecs with harmony_release::cli.
Promote to a fresh ADR-025
The shape proposed here doesn't disagree with ADR-012 — it implements it. A new ADR would invite reading them as competing decisions and split the conversation in two. Keeping this as ADR-012's first clarification (012-1) keeps the lineage explicit. Rejected for now; revisit if a future decision genuinely diverges from ADR-012's intent.
Consequences
Positive
- ADR-012 intent finally has a mechanism. The opinionated pipeline
becomes a composed
Vec<Box<dyn Score<T>>>— concrete, testable, identical local and in CI. - One framework, one mechanism. Release joins deploy in the Score-Topology-Capability pattern. No second concept to learn.
- Capability-driven swap-out. Move from docker→podman, Harbor→ECR,
helm→ko, Argo→Flux without touching any app's
AppSpec. ADR-003 rule held. - ~250 lines deleted from each app (per-component bin + release.sh
- workflow yaml duplication).
- Adding a component is one
AppSpecvalue, not a new binary. - LocalDev vs CI vs Production is topology selection at runtime (ADR-023 principle 6), not branching inside a Score.
Negative / costs
- Migration of
modules/applicationand theRustWebappexample to the new shape is a non-trivial PR (preserve the Leptos Dockerfile generation as aBuildableimpl). - New crate
harmony_release(or new module insideharmonycore). Sizing TBD during implementation; target the same ~ADR-023 scope. - Adapter coverage: at least three
ContainerBuilderadapters (local docker, CI docker, k3d-image-import) and twoOciRegistry/HelmRegistryadapters (hub.nationtech.io, noop) need to land for the first migration to be useful.
Risks to watch
- Capability surface creep. Resist adding a fourth or fifth
capability for every minor variant (e.g.
MultiArchContainerBuilder,SignedOciRegistry). Extend existing capabilities first; create new ones only when the swap-out test (ADR-003) holds. - Build context size.
BuildContextcould absorb every flag ever needed (build_args,secrets,platforms,targets,cache_from, …). Start small; grow with documented justification. Buildabletrait explosion. Per-language Dockerfile generators (RustLeptos,Python,Node, …) belong asBuildableimpls, not as variants onAppSpec. Resist adding them to core until at least two callers need each one.
Implementation order (separate PRs, each shippable)
harmony_releasecrate —AppSpec,ImageSource,ChartSource,ReleaseScore, capability trait stubs,--from-tagparsing. No adapters yet.- Local + CI capability adapters —
LocalDockerBuilder,CiDockerBuilder,HarborOciRegistry,HarborHelmRegistry. Compose intoK8sAnywhereTopologyand a newCiTopology. - Migrate fleet operator release to
AppSpec+ReleaseScore. Deleteharmony-fleet-releasebinary,release.sh,.gitea/scripts/resolve-release-version.sh. Workflow yaml goes to the ~15-line form. Validates the framework on a real app. - Add fleet agent + callout as
AppSpecentries when their pipelines land. One-line additions, no scaffolding. - Migrate
modules/applicationRustWebappexample to the new shape. Preserves the Leptos Dockerfile generator. Deletes the parallelApplication/ApplicationFeaturehierarchy. - (Stretch)
ContinuousDeliverycapability as a real abstraction above the currentArgoHelmScore. Enables Flux / direct-helm as peer providers. Out of v1 scope; ADR remains compatible.
Open questions
- Chart hydration genericism. Should
ChartSource::FromScoreexist? The deploy Score's resources are a near-superset of what the chart contains. If we get this right,ChartSource::Builderbecomes the exception, not the rule. Worth scoping in #5 or later. DeployTargetshape. Today fleet uses(namespace, image_tag). A typed enum (InCluster { namespace, … },MultiCluster { … },EdgeDevice { device_id, … }) may capture more. Defer to the agent-CD work (manual upgrades per device) where this gets exercised.- Image identity. The image's full ref (
<registry>/<project>/<name>:<version>) is currently built by string concatenation in three places. Should there be anImageReftype with parse/display impls? Probably; add in #1 if cost is small. - Buildable for non-Rust languages. Out of scope until a non-Rust app needs it, then add by demand.