8.1 From Source Code to Runnable Artifact
Prerequisites: Terminal, Git, Maven, Docker
Estimated time: 30 minutes
Exercise environment: Bash, curl 7.71+, JDK 17+, Maven 3.6.3+; the image section uses Docker Engine 28+ with default bridge/NAT networking
You are ready to continue when: you can distinguish between source code, test code, artifacts, images, deployment, release, and runtime, and locally validate a Java 17 artifact
The terminal, Git, debugger, build tools, and Docker are ready. Now turn source code into a service you can request locally: build an artifact, start a process, expose a port, and retain verifiable release evidence.
One Chain, Nine Different Actions
| Action | Output or State |
|---|---|
| Write | Source code and configuration changes |
| Compile | Bytecode or machine code |
| Test | Verification results for the candidate version |
| Package | JAR, archive, software package, etc., artifacts |
| Build image | OCI/Docker image containing runtime configuration |
| Publish artifact | Artifact enters the repository, obtaining a version or digest |
| Deploy | The candidate version begins to be used in the target environment |
| Release (release) | User traffic or feature switches are directed to this version |
| Run and observe | Health, logs, metrics, tracing, and alerts |
Deployment does not necessarily mean that users have already seen the new version. You can first deploy to an instance with no traffic, validate it, and then release traffic; or you can first deploy the code and then use a feature flag to enable the feature.
From an Empty Directory Make a Java 17 Service
This chapter uses the HTTP server built into JDK to avoid mixing in framework dependencies into the first release chain. First, set up the project:
workdir=$(mktemp -d)
release_lab_dir="$workdir/release-lab"
mkdir -p "$release_lab_dir/src/main/java/dev/atlas"
cd "$release_lab_dir"Write the complete pom.xml:
cat > pom.xml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.atlas</groupId>
<artifactId>release-lab</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<archive>
<manifest>
<mainClass>dev.atlas.App</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
XMLThen write the complete service entry point:
cat > src/main/java/dev/atlas/App.java <<'JAVA'
package dev.atlas;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
public final class App {
private App() {}
public static void main(String[] args) throws IOException {
String host = System.getenv().getOrDefault("HOST", "127.0.0.1");
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0);
server.createContext("/", exchange -> respond(exchange, 200, "Hello Atlas! Let us go!\n"));
server.createContext("/health", exchange -> respond(exchange, 200, "ok\n"));
String version = System.getenv().getOrDefault("APP_VERSION", "v1");
server.createContext("/version", exchange -> respond(exchange, 200, version + "\n"));
server.setExecutor(null);
Runtime.getRuntime().addShutdownHook(new Thread(() -> server.stop(1)));
server.start();
System.out.printf("listening=%d%n", server.getAddress().getPort());
}
private static void respond(HttpExchange exchange, int status, String text)
throws IOException {
byte[] body = text.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
exchange.sendResponseHeaders(status, body.length);
try (OutputStream output = exchange.getResponseBody()) {
output.write(body);
}
}
}
JAVA/health only shows that the process can handle this request. /version returns the environment's version marker, defaulting to v1. Neither checks a database or external service. JDK HttpServer matches context path prefixes; this example does not implement exact routing, authentication, or production traffic controls. See the JDK HttpServer documentation.
The service defaults to listening on 127.0.0.1. When running directly on the host, it won't be exposed to the local network. After building, let the operating system assign a temporary port and complete validation and stopping in the same terminal:
(
set -e
mvn --batch-mode verify
sha256sum target/release-lab-1.0.0.jar
HOST=127.0.0.1 PORT=0 java -jar target/release-lab-1.0.0.jar >app.log 2>&1 &
app_pid=$!
cleanup_app() {
kill "$app_pid" 2>/dev/null || true
wait "$app_pid" 2>/dev/null || true
}
trap cleanup_app EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
for attempt in 1 2 3 4 5; do
grep -q '^listening=' app.log && break
sleep 1
done
app_port=$(sed -n 's/^listening=//p' app.log)
test -n "$app_port"
curl --fail --silent --show-error "http://127.0.0.1:$app_port/health"
curl --fail --silent --show-error "http://127.0.0.1:$app_port/"
)The parentheses create a subshell whose exit cleanup also runs after a failure; SIGKILL cannot trigger a trap. Expect ok followed by the greeting. On macOS, use shasum -a 256. The first time Maven parses the plugin, it may need to connect to the internet. When the project is officially used, it is also recommended to submit the Maven Wrapper. In this case, verify will go through compilation, testing, and packaging. However, it does not generate tests on its own; "No tests to run" still indicates that additional regression test cases are needed.
Whether java -jar can run the JAR depends on how the manifest and dependencies are packaged. In this example, only JDK modules are used, so a regular JAR is sufficient. However, when there are third-party dependencies, a regular Maven JAR will not automatically include them. A Spring Boot executable JAR is repackaged by the corresponding plugin, and the two cannot be confused. Keep $workdir and $release_lab_dir, as the end-of-chapter experiment will also use them.
Putting Verified JARs into the Image
Save this as $release_lab_dir/Dockerfile. APP_VERSION is a public exercise marker that can be overridden at build time. Do not bake credentials into an image through ARG or ENV:
FROM eclipse-temurin:17-jre
ARG APP_VERSION=v1
ENV APP_VERSION=$APP_VERSION
WORKDIR /app
COPY target/release-lab-1.0.0.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"](
set -e
image_tag="workshop-ch08-release:${RANDOM}-${RANDOM}"
container_id=
cleanup_image() {
if [ -n "$container_id" ]; then
docker stop "$container_id" >/dev/null 2>&1 || true
fi
docker image rm "$image_tag" >/dev/null 2>&1 || true
}
trap cleanup_image EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
docker build -t "$image_tag" .
container_id=$(docker run --detach --rm --env HOST=0.0.0.0 \
--publish 127.0.0.1::8080 "$image_tag")
published=$(docker port "$container_id" 8080/tcp)
host_port=${published##*:}
curl --fail --silent --show-error --retry 10 --retry-all-errors \
--retry-delay 1 --max-time 2 "http://127.0.0.1:$host_port/health"
curl --fail --silent --show-error --max-time 2 "http://127.0.0.1:$host_port/version"
)Startup can briefly refuse or reset connections. These read-only health GETs allow at most 10 retries, with a 2-second limit per request. --retry-all-errors requires curl 7.71+; see the curl retry option.
If the base image is not cached, the build will access the registry. In an offline environment, it's important to prepare and verify the image source in advance. Production images should also fix the version or digest of an approved image, rather than relying long-term on movable tags.
One Build, Incremental Advancement
Build one candidate artifact for a release and promote it between environments. Production projects record the commit SHA, JAR checksum, image digest, build log, and test results. Development, pre-release, and production environments reference the same digest incrementally, rather than rebuilding for each environment.
This reduces dependency drift but does not automatically ensure security. Repeated builds, source provenance, signing, SBOMs, vulnerability scanning, and controlled configurations are still required.
Acceptance
- Can explain the boundaries of nine actions;
- Can explain the difference between
mvn verifyand "there are indeed tests"; - Can explain the difference between a regular JAR and a Spring Boot executable JAR;
- Can use
/healthto verify local JARs and containers; - Can explain why deployment should reference an immutable digest.
The manual sequence is complete once health and version requests pass and cleanup finishes. The next step is to hand over the verification to automation, while restricting its permissions.