This tutorial picks up where Pipelines Pack: Infrastructure Deployment left off and builds a real one. Yesterday's chain was four no-op stubs that logged and returned. Today we ship a working containerized-deployment pipeline: clone-relative checkout, Maven build, JAR publish to Reposilite, Docker image build, image push to a local registry, Compose deploy of the running app. The same archetype-and-knobs model runs the chain; the boundaries underneath now do the actual work.
The deliverable is a /deploy endpoint that, hit live, takes the hello-spring source repo from ~/working/hello-spring, runs it through the seven-slot chain, and leaves a running container behind. Recorded with --wanderland-record-to once, the same chain replays from cassette without touching network, registry, or shell.
The work splits into three sections. Section 1 is the standing infrastructure — a tiny Compose stack with a Docker registry and Reposilite (a Maven artifact repository), what Compose itself does, and where the config file lives. Section 2 brings that stack up. Section 3 builds the pipeline: project layout, three boundaries you write, two boundaries the pack ships, the archetype that wires them together, and a final run that produces a container hosting hello-spring.
When you're done you'll be able to:
/deploy endpoint overCompose is a tool for declaring multi-container stacks in a single YAML file and bringing the whole stack up with one command. The file describes services (containers to run), volumes (persistent storage), networks (private subnets connecting the services), and a few cross-cutting concerns like restart policy and environment variables. docker compose up reads the file, pulls the images that aren't local, creates the network and volumes, starts each service in dependency order, and detaches.
The shape of a Compose file:
services:
web:
image: nginx:1.27
ports: ["8080:80"]
volumes: ["./public:/usr/share/nginx/html:ro"]
depends_on: [api]
api:
image: my-org/api:0.5.0
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://db/app
depends_on: [db]
db:
image: postgres:16
volumes: ["db-data:/var/lib/postgresql/data"]
environment:
POSTGRES_PASSWORD: dev
volumes:
db-data:
Three services, one named volume. docker compose up -d (the -d detaches) brings all three up on a private network where service names resolve as DNS — the api container can connect to db:5432 without knowing or caring about IPs. docker compose down stops everything and removes the containers; volumes survive unless down -v is passed. docker compose logs <service> tails one service's output; docker compose ps lists what's running.
For local development infrastructure — the kind of thing your pipeline needs to push artifacts to or pull dependencies from — Compose is the lightest weight option that survives a reboot. The file is the source of truth; bringing the same stack up on a coworker's laptop is a git clone away.
This pipeline targets two pieces of standing infrastructure:
registry:2) at localhost:5001. Built images push here. The same image name prefix that points at localhost:5001/hello-spring would, in production, point at a real registry — ECR, GCR, GitHub Packages, a self-hosted Harbor. Swapping the prefix is the only difference; the pipeline boundaries don't change. The host port is :5001 rather than the registry's canonical :5000 because macOS binds :5000 to AirPlay Receiver by default; section 1 covers the collision in detail.localhost:8081. A lightweight Java-native Maven artifact repository. The pipeline mvn deploys built JARs to it; the Dockerfile downloads them back via a JAR URL when building the image. Reposilite is what stands in for Maven Central / Nexus / Artifactory in this loop.Both run as containers under one Compose file (docker-compose.infra.yml). Their data sits in named volumes so artifacts survive down/up cycles. The pipeline doesn't know or care that they're local; from the pipeline's view, they're just URLs.
The Compose file lives at the root of the spring-deploy-pipeline project:
# spring-deploy-pipeline/docker-compose.infra.yml
services:
registry:
image: registry:2
container_name: spring-deploy-registry
ports:
- "5001:5000"
volumes:
- registry-data:/var/lib/registry
environment:
REGISTRY_HTTP_SECRET: spring-deploy-local-dev
restart: unless-stopped
reposilite:
image: dzikoysk/reposilite:3.5.6
container_name: spring-deploy-reposilite
ports:
- "8081:8080"
volumes:
- reposilite-data:/app/data
tty: true
stdin_open: true
restart: unless-stopped
volumes:
registry-data:
reposilite-data:
Anatomy:
services.registry — the standard registry:2 image. Listens on :5000 inside the container; the ports: mapping exposes it on the host's :5001 rather than :5000. macOS Monterey and later bind :5000 and :7000 to AirPlay Receiver by default; a registry on host :5000 would hand its requests to AirPlay and answer 403 with Server: AirTunes. The container-side port stays canonical; the host-side port dodges the collision. Image refs use the host-visible port (localhost:5001/...) so docker push reaches the registry rather than AirPlay.REGISTRY_HTTP_SECRET pins the registry's HTTP secret to a stable string — without it the registry generates a random one at boot and warns about it on stdout. Single-instance local dev so the value is fixed.registry-data mounts at /var/lib/registry, where the registry image stores layers and manifests.services.reposilite — dzikoysk/reposilite:3.5.6 (the official image; pin to whatever your migration target is). Container listens on :8080; the host gets it on :8081 to keep it off any stray Java apps. Volume reposilite-data mounts at /app/data, the directory Reposilite uses for everything: configuration, the artifact tree, the user database, the access log.tty: true and stdin_open: true allocate an interactive console inside the Reposilite container. Reposilite's token-generate command runs through that console; without these settings the container has no TTY and the CLI subsystem stays inert (you'll see "Interactive CLI is not available in current environment" in the logs).restart: unless-stopped — survives a Docker restart but lets you docker compose stop cleanly when you want to.volumes: at the bottom declares the two named volumes so Compose creates and tracks them. Named volumes survive down; if you want to wipe state, docker compose down -v.Bring it up:
cd ~/working/spring-deploy-pipeline
docker compose -f docker-compose.infra.yml up -d
You should see two containers come up. Check them:
docker compose -f docker-compose.infra.yml ps
# NAME STATUS PORTS
# spring-deploy-registry Up 0.0.0.0:5001->5000/tcp
# spring-deploy-reposilite Up 0.0.0.0:8081->8080/tcp
The registry has no UI; ping its catalog endpoint:
curl -s http://localhost:5001/v2/_catalog
# {"repositories":[]}
If that returns 403 with Server: AirTunes/..., you're hitting macOS AirPlay — re-check that the host port in the Compose file is :5001 (or another non-AirPlay port) and that you brought the stack down + up after the change.
Bootstrap the Reposilite admin token. Reposilite ships with no users; the first one is created by the operator from inside the running container's CLI. Attach to the container:
docker attach spring-deploy-reposilite
You'll land at a prompt that reads Reposilite>. Run token-generate:
> token-generate admin m
# Reposilite responds with something like:
# Generated new access token for admin with permissions [m]:
# admin:K7qPx9mN2vR3Lf5jH8wT4yQ6sZ1aB...
m is the permissions flag for "manager" — a token allowed to deploy, manage repos, and create other tokens. Copy the secret half (the long random string after admin:); you'll paste it into config.yml in a moment.
Detach from the container with Ctrl+P then Ctrl+Q. Don't Ctrl+C — that kills the Reposilite process. The Ctrl+P Ctrl+Q sequence detaches without sending a signal.
The token is persisted in the SQLite database under the reposilite-data volume. Subsequent docker compose up cycles re-use the same database; the bootstrap is one-time. You'd repeat it only after docker compose down -v (which wipes the volume) or against a fresh Reposilite host.
Open http://localhost:8081 in a browser and log in with admin and the secret you just generated. The page lists the repositories Reposilite ships with by default (releases, snapshots, private); the snapshots repo is where the pipeline mvn deploys into.
The --token CLI flag exists in the documentation as a way to seed a temporary token at startup, but the dzikoysk/reposilite 3.5.6 image's entrypoint silently drops it whether or not a TTY is attached. The token-generate console command is the working path for this image.
A couple of operational notes worth carrying forward:
/app/data. Migration between hosts is rsync of that directory plus a DNS repoint. The named volume in the Compose file abstracts that path; your real Reposilite host would have it as a directory, and the SQLite DB at /app/data/database.db carries the tokens you bootstrapped.docker compose down followed by docker compose up doesn't require re-bootstrapping. Only down -v (which removes the volume) sends you back through the token-generate step./app/data to a host path instead of using a named volume, the container needs that path to be writable by its non-root user. Named volumes (the shape used here) don't have the issue.When you're done with the stack, tear it down:
docker compose -f docker-compose.infra.yml down # stop, keep volumes (token persists)
docker compose -f docker-compose.infra.yml down -v # stop, wipe volumes (re-bootstrap on next up)
You won't tear it down for the rest of the tutorial. Leave it up. Section 3 fills the registry with a built image and Reposilite with a deployed JAR.
While the stack is running, you can browse what the pipeline is going to write. Reposilite's UI at http://localhost:8081 will show artifacts under snapshots/ once the pipeline runs. The registry's catalog endpoint (http://localhost:5001/v2/_catalog) lists pushed images. Both are empty right now; that changes by the end of section 3.
The deliverable is a wanderland-core service called spring-deploy-pipeline whose /deploy route runs the seven-slot chain against the hello-spring source repo. Three repos are involved:
~/working/
├── hello-spring/ # the app being deployed
│ ├── pom.xml
│ ├── src/main/java/dev/wanderland/hello/
│ │ ├── HelloApplication.java
│ │ └── HelloController.java
│ ├── src/main/resources/application.yml
│ └── Dockerfile
│
├── spring-deploy-pipeline/ # operational repo: config + templates + cassettes
│ ├── Gemfile
│ ├── config.yml
│ ├── config.ru
│ ├── docker-compose.infra.yml # section 1
│ ├── templates/
│ │ └── hello-spring.compose.yml.tpl
│ └── spec/
│ └── fixtures/
│ └── cassettes/
│
└── wanderland-pipelines-pack/ # the gem holding archetype + boundaries
└── lib/
└── wanderland_pipelines_pack/
├── archetypes/
│ ├── infrastructure_deployment.yml
│ └── containerized_deployment.yml
└── boundaries/
├── start_run.rb # ships with the pack
├── publish_jar.rb # ships with the pack
├── publish_image.rb # ships with the pack
├── complete_run.rb # ships with the pack
├── compile_jar.rb # you write
├── docker_build.rb # you write
└── compose_deploy.rb # you write
Three concerns, three repos. hello-spring/ is an ordinary Spring Boot codebase that knows nothing about wanderland. spring-deploy-pipeline/ is the operational repo — config, the Compose stack for the registry and Reposilite, the templates, the cassettes; this is where you cd to run wanderland --type test. wanderland-pipelines-pack/ is the gem that ships the archetype and the boundaries.
The first user-pluggable slot. Reads source_path (the directory holding pom.xml), runs mvn package -DskipTests through the :mvn shell adapter, emits the path to the built JAR.
Create wanderland-pipelines-pack/lib/wanderland_pipelines_pack/boundaries/compile_jar.rb:
# frozen_string_literal: true
module WanderlandPipelinesPack
module Boundaries
class CompileJar
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:compile_jar",
name: "CompileJar",
roles: [:boundary],
type: :service,
scopes: [:execute]
).freeze
boundary :compile_jar,
identity: IDENTITY,
requirements: [:execute],
capabilities: [:build],
description: "Compile a Maven project's JAR",
input_shape: {
"args" => {
"source_path" => {
required: true,
description: "Repo root containing pom.xml — the directory mvn runs against"
},
"compile_args" => {
required: false,
description: "Extra mvn args, e.g. ['-Pprofile-x', '-DskipITs']"
}
}
},
output_shape: {
"jar_path" => true,
"source_path" => true,
"duration_s" => true
},
adapters: [:mvn]
def call(input)
source_path = input.find_or_fail("args.source_path")
extra_args = Array(input.find("args.compile_args"))
mvn = input.adapter(:mvn)
info { "#{PLAY} mvn package #{source_path}" }
result = mvn.run(
"-f", File.join(source_path, "pom.xml"),
"package",
"-DskipTests",
*extra_args
)
if result[:exit_code] != 0
return Wanderland::Signal.halt(
status: 502,
error: "mvn package failed (exit #{result[:exit_code]}): #{result[:stderr].lines.last(8).join.strip}"
)
end
jar_path = locate_jar(source_path)
unless jar_path
return Wanderland::Signal.halt(
status: 502,
error: "mvn package succeeded but no JAR found under #{source_path}/target/"
)
end
Wanderland::Signal.ok(
jar_path: jar_path,
source_path: source_path,
duration_s: result[:duration_s]
)
end
private
def locate_jar(source_path)
target = File.join(source_path, "target")
return nil unless Dir.exist?(target)
candidates = Dir.glob(File.join(target, "*.jar")).reject { |f| f.end_with?(".original.jar") }
candidates.max_by { |f| File.mtime(f) }
end
end
end
end
Anatomy:
IDENTITY constant is a frozen Wanderland::Identity describing what the boundary is, what role it plays, and what scopes its work needs. Same shape every other boundary in the corpus uses (instance_readout, start_run, publish_jar). The boundary :compile_jar call passes that identity through alongside requirements: [:execute], which enforce_denials checks against the runtime's denied-scopes context before dispatch.capabilities: [:build] classifies the work for matching against the framework's default when: predicates and for filtering in introspection (/inspect/boundary/compile_jar). requirements: is access; capabilities: is verb.input_shape declares each parameter the boundary reads, with required: flagging whether the boundary halts when it's absent and description: giving the inspector a one-liner per field. source_path is required (the boundary's find_or_fail enforces it); compile_args is optional (the boundary's find returns nil when absent, and Array() turns nil into the empty list). The same shape supports constraints: for ShapeMatcher rules and metadata: for free-form annotations — see Boundary for the full vocabulary.info { "#{PLAY} mvn package #{source_path}" } logs through Wanderland::Logging, which the boundary picks up automatically from include Wanderland::Boundary. The log prefix is the registered boundary name (compile_jar), not the Ruby class name. PLAY, DONE, OK, FAIL, ARROW are constants from Wanderland::Logging — reference them by short name from inside the boundary so glyph use stays consistent across the codebase.adapters: [:mvn] declares the adapter contract. The runtime checks at mount time that an adapter named :mvn is mounted; missing names raise Wanderland::Adapters::UnknownAdapterError with the registered list.mvn.run(...) is the shell adapter's facade. Returns a uniform hash: {exit_code:, stdout:, stderr:, duration_s:}. Same shape across :live, :replay, :mock.locate_jar walks target/ for the freshest *.jar skipping the .original.jar Spring Boot writes alongside the repackaged uber-jar. The boundary doesn't trust mvn's stdout for the JAR path — too many edge cases.wanderland-pipelines-pack gets compile_jar available on the boundary registry.Reads jar_url from the previous slot's context (the URL mvn announced when it uploaded the artifact), runs docker build with that URL as the JAR_URL build-arg, emits the image ref.
Create wanderland-pipelines-pack/lib/wanderland_pipelines_pack/boundaries/docker_build.rb:
# frozen_string_literal: true
module WanderlandPipelinesPack
module Boundaries
class DockerBuild
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:docker_build",
name: "DockerBuild",
roles: [:boundary],
type: :service,
scopes: [:execute]
).freeze
boundary :docker_build,
identity: IDENTITY,
requirements: [:execute],
capabilities: [:build],
description: "Build a container image, pulling the published JAR from the artifact repository",
input_shape: {
"args" => {
"source_path" => {
required: true,
description: "Path to the build context (where the Dockerfile lives)"
},
"image_name" => {
required: true,
description: "Registry-qualified image name without tag, e.g. localhost:5001/hello-spring"
},
"image_tag" => {
required: false,
description: "Tag for the built image; defaults to 'latest'"
},
"docker_build_args" => {
required: false,
description: "Extra docker build flags, e.g. ['--no-cache', '--platform', 'linux/amd64']"
}
}
},
output_shape: {
"image_ref" => true,
"image_name" => true,
"image_tag" => true,
"jar_url" => true,
"duration_s" => true
},
adapters: [:docker]
def call(input)
source_path = input.find_or_fail("args.source_path")
image_name = input.find_or_fail("args.image_name")
image_tag = input.find("args.image_tag") || "latest"
ref = "#{image_name}:#{image_tag}"
# publish_jar scraped the actual upload URL out of mvn's
# stdout and put it in context. We use it verbatim — no
# path reconstruction, no snapshot timestamp resolution
# in this boundary. Whatever mvn published, that's what
# the Dockerfile pulls.
jar_url = input.find_or_fail("context.jar_url")
extra_args = Array(input.find("args.docker_build_args"))
docker = input.adapter(:docker)
info { "#{PLAY} build #{ref} #{ARROW} #{jar_url}" }
result = docker.run(
"build",
"--build-arg", "JAR_URL=#{jar_url}",
*extra_args,
"-t", ref,
source_path
)
if result[:exit_code] != 0
return Wanderland::Signal.halt(
status: 502,
error: "docker build failed (exit #{result[:exit_code]}): #{result[:stderr].lines.last(8).join.strip}"
)
end
Wanderland::Signal.ok(
image_ref: ref,
image_name: image_name,
image_tag: image_tag,
jar_url: jar_url,
duration_s: result[:duration_s]
)
end
end
end
end
Anatomy:
context.jar_url is the URL mvn actually uploaded to. publish_jar scrapes it from the deploy plugin's Uploaded to local: <url> lines — the same lines you see when running mvn deploy by hand. Snapshot deploys land at timestamped paths (hello-spring-0.1.0-20260509.050411-17.jar) that don't match a <artifact>-<version>.jar reconstruction; release deploys do match, but reading the URL from mvn's output works for both. Tool output as the source of truth — same shape Packer uses to extract AMI ids, terraform uses to extract output values.IDENTITY, requirements: [:execute], capabilities: [:build], input_shape, output_shape, adapters: [:docker] — same registration shape as compile_jar. Each pipeline boundary declares its identity and access requirements alongside the operation-level metadata.image_tag and docker_build_args are optional — read via input.find, not declared in input_shape. image_tag defaults to "latest" when absent; docker_build_args defaults to an empty list. The required fields (source_path, image_name) are the only ones in input_shape because they're the ones the boundary halts on via find_or_fail.info { "#{PLAY} build #{ref} #{ARROW} #{jar_url}" } uses the PLAY and ARROW constants from Wanderland::Logging so the glyphs stay consistent across the codebase. Logs as [docker_build] ▶ build ... → ....localhost:5001/hello-spring:0.1.0-SNAPSHOT is what docker push will accept against the local registry sidecar. The :5001 host port is the AirPlay-dodge from section 1; when you swap to a real registry, only image_name in the config changes.publish_jar points at localhost:8081 (Reposilite's host-side address). That URL is reachable from mvn (running on your Mac), but the docker build's curl runs inside the build sandbox where localhost is the container's own loopback. The build won't reach Reposilite without help. Fix by passing --add-host=host.docker.internal:host-gateway to docker build and rewriting the URL's host segment from localhost to host.docker.internal before threading it into the build-arg. Production registries have routable hostnames so this only matters for the local-dev path.Renders a Compose template against the image ref, writes it under a per-run output directory, runs docker compose up -d.
Create wanderland-pipelines-pack/lib/wanderland_pipelines_pack/boundaries/compose_deploy.rb:
# frozen_string_literal: true
require "fileutils"
module WanderlandPipelinesPack
module Boundaries
class ComposeDeploy
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:compose_deploy",
name: "ComposeDeploy",
roles: [:boundary],
type: :service,
scopes: [:execute, :write]
).freeze
boundary :compose_deploy,
identity: IDENTITY,
requirements: [:execute, :write],
capabilities: [:deploy],
description: "Render a Compose template and bring up the stack",
input_shape: {
"args" => {
"compose_template" => {
required: true,
description: "Path to the Compose template (with ${PLACEHOLDER} tokens) to render"
},
"compose_parameters" => {
required: true,
description: "Hash of placeholder values; IMAGE_REF / IMAGE_NAME / IMAGE_TAG are added automatically"
},
"output_root" => {
required: true,
description: "Directory under which each run writes its rendered compose.yml; one subdirectory per run_id"
}
}
},
output_shape: {
"compose_path" => true,
"image_ref" => true,
"parameters" => true,
"duration_s" => true
},
adapters: [:docker]
def call(input)
template_path = input.find_or_fail("args.compose_template")
parameters = input.find_or_fail("args.compose_parameters")
output_root = input.find_or_fail("args.output_root")
# Context's `[]` does last-writer-wins key projection across
# all upstream crossings' results — no boundary-name segment
# in the path. start_run emitted run_id; publish_image
# emitted image_ref.
run_id = input.find_or_fail("context.run_id")
image_ref = input.find_or_fail("context.image_ref")
bindings = parameters.merge(
"IMAGE_REF" => image_ref,
"IMAGE_NAME" => image_ref.split(":").first,
"IMAGE_TAG" => image_ref.split(":").last
)
rendered = render_template(template_path, bindings)
compose_path = write_rendered_compose(output_root, run_id, rendered)
docker = input.adapter(:docker)
info { "#{PLAY} docker compose up -d (#{compose_path})" }
result = docker.run("compose", "-f", compose_path, "up", "-d")
if result[:exit_code] != 0
return Wanderland::Signal.halt(
status: 502,
error: "docker compose up failed (exit #{result[:exit_code]}): #{result[:stderr].lines.last(8).join.strip}"
)
end
Wanderland::Signal.ok(
compose_path: compose_path,
image_ref: image_ref,
parameters: bindings,
duration_s: result[:duration_s]
)
end
private
def render_template(template_path, bindings)
template = File.read(template_path)
bindings.reduce(template) do |acc, (k, v)|
acc.gsub("${#{k}}", v.to_s)
end
end
def write_rendered_compose(output_root, run_id, rendered)
dir = File.join(output_root, run_id)
FileUtils.mkdir_p(dir)
path = File.join(dir, "compose.yml")
File.write(path, rendered)
path
end
end
end
end
Anatomy:
IDENTITY, requirements: [:execute, :write], capabilities: [:deploy], input_shape, output_shape, adapters: [:docker] — same registration shape as compile_jar and docker_build, with [:execute, :write] because the boundary writes the rendered compose file before shelling out.context.run_id and context.image_ref use Context#[]'s last-writer-wins key projection. Across the seven-slot chain, start_run is the only boundary that emits run_id, and publish_image is the only one that emits image_ref — so LWW resolves them unambiguously. The path has no boundary-name segment because Context#[] doesn't navigate by boundary; it scans crossings' result hashes for the named key. To filter explicitly by boundary use input["context"].publish_image.dig("result", "image_ref") instead.info { "#{PLAY} docker compose up -d ..." } logs as [compose_deploy] ▶ docker compose up -d ... — the prefix comes from the boundary registration via Wanderland::Boundary's log_prefix override; PLAY is the standard glyph from Wanderland::Logging.templates/hello-spring.compose.yml.tpl in spring-deploy-pipeline), substitutes ${PLACEHOLDER} tokens against compose_parameters plus three synthesized image bindings, writes the result under <output_root>/<run_id>/compose.yml, and shells out to docker compose -f <that-path> up -d.run_id. If you re-run, the previous compose file stays put — easy to compare what changed between runs.IMAGE_REF / IMAGE_NAME / IMAGE_TAG are always available in the template even if the user doesn't list them in compose_parameters. The pipeline knows the image; the user shouldn't have to thread it manually.up -d for detached mode. Add --wait if you want the boundary to block until services are healthy; out of scope here because healthcheck wiring is per-app.The same archetype works in your existing vivarta project. Open ~/working/vivarta/config.yml, find the /deploy route from yesterday's pipelines-pack tutorial, and swap its archetype:
# Before — yesterday's no-op chain
routes:
/deploy:
method: post
name: deploy
archetype: infrastructure_deployment
provisioner: shell_provisioner
system: web
environment: prod
template: stacks/web.yml
parameters:
instance_type: t3.medium
# After — today's real chain
routes:
/deploy:
method: post
name: deploy
archetype: containerized_deployment
compiler: compile_jar
image_builder: docker_build
deployer: compose_deploy
system: hello-spring
environment: dev
source_path: ../hello-spring
repository_url: http://admin:<your-reposilite-secret>@localhost:8081/snapshots
repository_id: local
image_name: localhost:5001/hello-spring
image_tag: 0.1.0-SNAPSHOT
compose_template: templates/hello-spring.compose.yml.tpl
compose_parameters:
PORT: "8080"
ENV: dev
output_root: /tmp/vivarta-deploy
Two operational changes the route needs to actually run:
wanderland-pipelines-pack. Vivarta already has the pack on its Gemfile from yesterday's tutorial, so compile_jar, docker_build, and compose_deploy are already on the boundary registry alongside publish_jar and publish_image. No copying or per-project boundary directory needed.templates/hello-spring.compose.yml.tpl into vivarta (copy it from ~/working/spring-deploy-pipeline/templates/). The Compose template is project-shaped — one per app vivarta deploys.repository_url is the same one you generated for spring-deploy-pipeline (both projects target the same Reposilite instance, so the same credentials work).Then run the same wanderland --type test config.yml against vivarta. The /deploy route now runs the seven-slot containerized_deployment chain instead of the no-op chain. The /readout/:instance_id AWS route is untouched — different archetype, different chain, sharing the engine.
Putting the new boundaries inside the pack is the lever that makes them reusable. The pack auto-loads every *.rb file under lib/wanderland_pipelines_pack/boundaries/ at gem-require time; the boundary :name, ... DSL inside each one self-registers. Any project that bundles wanderland-pipelines-pack immediately sees compile_jar, docker_build, compose_deploy on the boundary registry alongside publish_jar and publish_image. Vivarta picks them up the same way spring-deploy-pipeline does — one Gemfile line, no per-project copying.
~/working/
├── hello-spring/ # source repo (unchanged)
│ ├── pom.xml
│ ├── src/...
│ └── Dockerfile
│
├── spring-deploy-pipeline/ # operational repo
│ ├── Gemfile
│ ├── Gemfile.lock
│ ├── config.ru
│ ├── config.yml
│ ├── docker-compose.infra.yml
│ ├── templates/
│ │ └── hello-spring.compose.yml.tpl
│ └── spec/
│ └── fixtures/
│ └── cassettes/
│ ├── mvn/
│ │ ├── 4f1a... .yml # mvn package
│ │ └── 9c8e... .yml # mvn deploy
│ └── docker/
│ ├── a72b... .yml # docker build
│ ├── d139... .yml # docker push
│ └── bb50... .yml # docker compose up
│
└── wanderland-pipelines-pack/
└── lib/
└── wanderland_pipelines_pack/
├── archetypes/
│ ├── infrastructure_deployment.yml
│ └── containerized_deployment.yml
└── boundaries/
├── start_run.rb
├── compile_jar.rb # added
├── publish_jar.rb
├── docker_build.rb # added
├── publish_image.rb
├── compose_deploy.rb # added
└── complete_run.rb
Seven boundaries in the pack (four already shipping, three you added), one new archetype, three shell adapters, five cassettes per recorded run. The operational repo holds config, the Compose stack, the template, and the cassettes — everything specific to this deploy. The pack holds the reusable mechanics. Same shape extends to other archetypes you build: a scanned_deployment adds an image-scanning boundary in the chain; a kubernetes_deployment swaps compose_deploy for a kubectl_apply boundary. The pack ships the building blocks; archetypes wire them.
:advaita:<system>:<env>:runs:<run_id>:.... Each boundary in the chain writes a crossing under that prefix; replay-by-run-id lets you inspect, retry, or audit a specific run from storage. Runs become first-class objects.kubectl shell adapter and a matching kubernetes_deployment archetype. Same pattern, swap compose_deploy for kubectl_apply. The rest of the chain stays.build_image and publish_image. A custom archetype demonstrates how to insert intermediate work without forking the pack.rsync of /app/data plus a DNS repoint, with a cutover window when builds pause.wanderland.dev