diff --git a/.github/actions/managed-java/action.yml b/.github/actions/managed-java/action.yml deleted file mode 100644 index ad03737..0000000 --- a/.github/actions/managed-java/action.yml +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-FileCopyrightText: The devcontainer.java Authors -# SPDX-License-Identifier: 0BSD - -name: Use a managed Java version -description: DO NOT EDIT THIS FILE DIRECTLY -runs: - using: composite - steps: - - name: Set up Java - uses: graalvm/setup-graalvm@v1 - with: - version: latest - java-version: 17 - cache: maven diff --git a/.github/actions/managed-maven/action.yml b/.github/actions/managed-maven/action.yml deleted file mode 100644 index c9dff1c..0000000 --- a/.github/actions/managed-maven/action.yml +++ /dev/null @@ -1,19 +0,0 @@ -# SPDX-FileCopyrightText: The devcontainer.java Authors -# SPDX-License-Identifier: 0BSD - -name: Use a managed Java version with Maven credentials -description: DO NOT EDIT THIS FILE DIRECTLY -runs: - using: composite - steps: - - name: Set up Java - uses: actions/setup-java@v3 - with: - java-version: 17 - java-package: jdk - architecture: x64 - distribution: temurin - cache: maven - server-id: ossrh - server-username: MAVEN_CENTRAL_USERNAME - server-password: MAVEN_CENTRAL_TOKEN diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6d669cf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: The ilo Authors +# SPDX-License-Identifier: 0BSD + +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + assignees: + - sebhoss + - package-ecosystem: maven + directory: / + schedule: + interval: daily + assignees: + - sebhoss diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index d926442..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: The devcontainer.java Authors -# SPDX-License-Identifier: 0BSD - -name: CodeQL -on: - schedule: - - cron: 42 3 * * TUE -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: [ java ] - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - uses: ./.github/actions/managed-java - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31acd51..4436d31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - id: checkout name: Clone Git Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - id: last_release @@ -38,8 +38,16 @@ jobs: if: steps.commits.outputs.count > 0 run: echo "iso8601=$(date --utc --iso-8601=seconds)" >> $GITHUB_OUTPUT - id: setup-java - uses: ./.github/actions/managed-maven + name: Set up Java if: steps.commits.outputs.count > 0 + uses: actions/setup-java@v4 + with: + java-version: 25 + distribution: temurin + cache: maven + server-id: ossrh + server-username: MAVEN_CENTRAL_USERNAME + server-password: MAVEN_CENTRAL_TOKEN - id: gpg name: GPG Key if: steps.commits.outputs.count > 0 @@ -79,25 +87,3 @@ jobs: draft: false prerelease: false generate_release_notes: true - - id: mail - name: Send Mail - if: steps.commits.outputs.count > 0 - uses: dawidd6/action-send-mail@v3 - with: - server_address: ${{ secrets.MAIL_SERVER }} - server_port: ${{ secrets.MAIL_PORT }} - username: ${{ secrets.MAIL_USERNAME }} - password: ${{ secrets.MAIL_PASSWORD }} - subject: ${{ github.event.repository.name }} version ${{ steps.release.outputs.version }} published - body: See ${{ steps.create_release.outputs.url }} for details. - to: ${{ secrets.MAIL_RECIPIENT }} - from: ${{ secrets.MAIL_SENDER }} - - id: matrix - name: Send Matrix Message - if: steps.commits.outputs.count > 0 - uses: s3krit/matrix-message-action@v0.0.3 - with: - room_id: ${{ secrets.MATRIX_ROOM_ID }} - access_token: ${{ secrets.MATRIX_ACCESS_TOKEN }} - message: ${{ github.event.repository.name }} version [${{ steps.release.outputs.version }}](${{ steps.create_release.outputs.url }}) published - server: ${{ secrets.MATRIX_SERVER }} diff --git a/.github/workflows/update-parent.yml b/.github/workflows/update-parent.yml index 02be7ab..47b41b0 100644 --- a/.github/workflows/update-parent.yml +++ b/.github/workflows/update-parent.yml @@ -9,14 +9,30 @@ jobs: parent: runs-on: ubuntu-latest steps: - - name: Clone Git Repository - uses: actions/checkout@v4 - - uses: ./.github/actions/managed-java - - name: Update Parent + - id: checkout + name: Clone Git Repository + uses: actions/checkout@v6 + - id: graal + name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 + with: + version: latest + java-version: 25 + github-token: ${{ secrets.GITHUB_TOKEN }} + - id: cache + name: Cache Maven Repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + - id: parent + name: Update Parent run: mvn --batch-mode --define generateBackupPoms=false versions:update-parent - id: cpr name: Create Pull Request - uses: peter-evans/create-pull-request@v5 + uses: peter-evans/create-pull-request@v6 with: token: ${{ secrets.PAT }} commit-message: Update parent to latest version diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 2fe145c..644bb13 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -9,8 +9,26 @@ jobs: verify: runs-on: ubuntu-latest steps: - - name: Clone Git Repository - uses: actions/checkout@v4 - - uses: ./.github/actions/managed-java - - name: Build with Maven + - id: checkout + name: Clone Git Repository + uses: actions/checkout@v6 + - id: graal + name: Set up GraalVM + uses: graalvm/setup-graalvm@v1 + with: + version: latest + java-version: 25 + github-token: ${{ secrets.GITHUB_TOKEN }} + - id: cache + name: Cache Maven Repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + # The native profile runs PMD + SpotBugs (bound to verify) and builds and runs the + # native-image smoke check, so a green run proves both code quality and native compatibility. + - id: verify + name: Verify Project run: mvn --batch-mode --activate-profiles=native verify diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41a8ad7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ + + +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A single-purpose Java library that parses and builds [devcontainer.json](https://containers.dev/implementors/json_reference/) files. Published to Maven Central as `wtf.metio.devcontainer:devcontainer.java`. No CLI, no runtime — it is a model + Jackson glue. + +## Commands + +JDK 25 (`global.jdkVersion` in `pom.xml`), Maven. Inherits most config from the `wtf.metio.maven:maven-parent` POM. That parent does **not** manage third-party dependency versions, so this POM pins them itself: Jackson and JUnit via imported BOMs in ``, record-builder via the `version.record-builder` property (shared between the dependency and the annotation-processor path — they must move together). + +- Build & test: `mvn --batch-mode verify` +- Native-compatibility check (what CI's PR check runs — the GraalVM `native` profile): `mvn --batch-mode --activate-profiles=native verify` +- Single test class: `mvn test -Dtest=DevcontainerParsingTest` + +`verify` also runs the quality gates (see below). `.github/workflows/verify.yml` runs the native command above on GraalVM 25, so PMD, SpotBugs, and the native smoke check all gate every PR. + +The local machine has no JDK/Maven installed; run builds through `ilo` (see global instructions) or in the GraalVM container CI uses. + +## Code quality gates (bound to `verify`) + +- **PMD** (`check` + `cpd-check`, from the parent) with a focused ruleset in `config/pmd/ruleset.xml`. The generated-sources root is excluded via `excludeRoots`. +- **SpotBugs** (`check`) with `config/spotbugs/exclude.xml`. The exclusions are deliberate: representation-exposure findings are expected for records holding `List`/`Map`, and the generated `*Builder` classes are not ours to restyle. + +Both fail the build on findings. The PMD engine is pinned to 7.x so it can parse JDK 25 sources. When a quality gate flags generated code, exclude the generated artifact (root or class pattern) rather than the rule globally. + +## Architecture + +The schema is modeled as a graph of Java **records**, rooted at `Devcontainer`. Supporting records (`Build`, `Command`, `HostRequirements`, `PortAttribute`) and enums (`OnAutoForward`, `Protocol`, `ShutdownAction`, `UserEnvProbe`, `WaitFor`) cover the nested and constrained fields. Records carry the spec docs as Javadoc on each component. + +- **record-builder annotation processor** (`io.soabase.record-builder`, `provided` scope) generates a `Builder` for every `@RecordBuilder` record at compile time. Records implement `Builder.With` to get wither methods. The build method is renamed to `create()` via `@RecordBuilder.Options`. These `*Builder` classes do not exist until you compile — don't go looking for them in source. +- **Jackson 3** (`tools.jackson.*` packages, `tools.jackson` BOM coordinates — not the Jackson 2 `com.fasterxml.jackson.*`). The mapper is immutable and built via `JsonMapper.builder()...build()`; there are no `enable()`/`disable()` mutators on an instance. Jackson 3 throws unchecked `JacksonException`, so the `parse(...)` methods declare no checked `IOException`. +- **Parsing** lives entirely in `Devcontainer.parse(...)` overloads (Path/File/String), backed by `defaultObjectMapper()`: a `JsonMapper` that disables `FAIL_ON_UNKNOWN_PROPERTIES` (forward-compat with spec additions) and enables `ACCEPT_SINGLE_VALUE_AS_ARRAY` (the spec lets array fields appear as a single scalar). +- **`Command` is a polymorphic field.** In the spec a lifecycle command may be a string, a string array, or an object of named commands. `Command` holds all three (`string`, `array`, `object`) with at most one non-null, populated by `CommandDeserializer` (a `StdDeserializer` wired via `@JsonDeserialize`). The `object` variant recurses into more `Command`s. +- **JPMS:** `module-info.java` opens the package to `tools.jackson.databind` (needed for reflective deserialization) and requires record-builder + java.compiler as `static` (compile-only). + +## GraalVM native image — keep reflect-config.json in sync + +The library's contract is that downstream consumers (e.g. ilo) can native-compile an app that depends on it. `src/main/resources/META-INF/native-image/.../reflect-config.json` registers every record and enum for reflection so Jackson works in a native image. **When you add a new record or enum type to the model, add a corresponding entry to this file**, or native-image deserialization will fail at runtime (not compile time) in consumers. + +The `native` profile verifies this contract by mimicking a consumer: it adds `src/native/java` as a source root (so the stand-in never ships in the published jar), builds `NativeImageSmokeTest` into a native image via the plugin's `compile-no-fork` goal, then runs it (`exec`). Building exercises `reflect-config.json`; running surfaces missing runtime reflection metadata. When you add a model field worth covering, exercise it in that smoke main. It deliberately does **not** native-compile the JUnit test suite — that would only test JUnit's own native compatibility, not the library's. + +## Tests are data-driven + +`DevcontainerParsingTest` is a `@TestFactory` that pairs JSON fixtures in `src/test/resources/*.json` with assertion lambdas. To test a new field or parsing case: add a fixture file and a `Map.entry("fixture.json", devcontainer -> ...)` with the expected values. `DevcontainerBuilderTest` covers the generated builders/withers. + +## Conventions + +- Every file carries an SPDX header; the project license is **0BSD**. Match the existing header when adding files. +- DCO sign-off is required: commit with `git commit --signoff`. +- Releases are **calendar-versioned and automated** (`.github/workflows/release.yml`, Friday cron, version = `date +'%Y.%-m.%-d'`). A release only happens when commits since the last tag touched `src/main/java` or `pom.xml`. The POM version stays `0.0.0-SNAPSHOT` in the tree; the real version is injected at release time. diff --git a/README.md b/README.md index 6d93d0f..3262266 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: The devcontainer.java Authors SPDX-License-Identifier: 0BSD --> -# devcontainer.java [![Chat](https://img.shields.io/badge/matrix-%23talk.metio:matrix.org-brightgreen.svg?style=social&label=Matrix)](https://matrix.to/#/#talk.metio:matrix.org) +# devcontainer.java Java implementation for the [devcontainer](https://containers.dev/implementors/json_reference/) file specification. diff --git a/config/pmd/ruleset.xml b/config/pmd/ruleset.xml new file mode 100644 index 0000000..efc7264 --- /dev/null +++ b/config/pmd/ruleset.xml @@ -0,0 +1,22 @@ + + + + + + Focused, low-noise rules: dead code, unnecessary imports, and cognitive complexity. Kept + deliberately small so violations stay actionable. + + + + + + + + + diff --git a/config/spotbugs/exclude.xml b/config/spotbugs/exclude.xml new file mode 100644 index 0000000..509c967 --- /dev/null +++ b/config/spotbugs/exclude.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/dev/Containerfile b/dev/Containerfile new file mode 100644 index 0000000..e3609bf --- /dev/null +++ b/dev/Containerfile @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: The devcontainer.java Authors +# SPDX-License-Identifier: 0BSD + +FROM ghcr.io/graalvm/native-image-community:25 + +# Install Maven +ENV MAVEN_HOME /usr/share/maven + +COPY --from=docker.io/library/maven:3-eclipse-temurin-21 ${MAVEN_HOME} ${MAVEN_HOME} +COPY --from=docker.io/library/maven:3-eclipse-temurin-21 /usr/local/bin/mvn-entrypoint.sh /usr/local/bin/mvn-entrypoint.sh +COPY --from=docker.io/library/maven:3-eclipse-temurin-21 /usr/share/maven/ref/settings-docker.xml /usr/share/maven/ref/settings-docker.xml + +RUN ln -s ${MAVEN_HOME}/bin/mvn /usr/bin/mvn + +ENV MAVEN_CONFIG "/root/.m2" + +ENTRYPOINT ["/usr/local/bin/mvn-entrypoint.sh"] +CMD ["mvn"] diff --git a/java.properties b/java.properties deleted file mode 100644 index dc1a8ef..0000000 --- a/java.properties +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: The devcontainer.java Authors -# SPDX-License-Identifier: 0BSD - -javaVersion=17 diff --git a/pom.xml b/pom.xml index 10cd181..b89d4f4 100644 --- a/pom.xml +++ b/pom.xml @@ -15,9 +15,9 @@ - wtf.metio.maven.parents - maven-parents-java-prototype - 2024.1.12 + wtf.metio.maven + maven-parent + 2026.5.29 @@ -63,25 +63,52 @@ - ${javaVersion} + 25 + 3.1.4 + 52 + 6.1.0 + + + + + + + + tools.jackson + jackson-bom + ${version.jackson} + pom + import + + + org.junit + junit-bom + ${version.junit} + pom + import + + + + - com.fasterxml.jackson.core + tools.jackson.core jackson-core - com.fasterxml.jackson.core + tools.jackson.core jackson-databind io.soabase.record-builder record-builder-core + ${version.record-builder} provided @@ -101,30 +128,125 @@ io.soabase.record-builder record-builder-processor - 35 + ${version.record-builder} + + + org.apache.maven.plugins + maven-pmd-plugin + + true + true + 100 + + config/pmd/ruleset.xml + + + + ${project.build.directory}/generated-sources/annotations + + + + + net.sourceforge.pmd + pmd-core + 7.25.0 + + + net.sourceforge.pmd + pmd-java + 7.25.0 + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + Max + config/spotbugs/exclude.xml + + + + + check + + verify + + + + native + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.1 + + + add-native-smoke-source + generate-sources + + add-source + + + + src/native/java + + + + + org.graalvm.buildtools native-maven-plugin true + + devcontainer-native-smoke + wtf.metio.devcontainer.NativeImageSmokeTest + + + + build-native-smoke + + compile-no-fork + + package + + + + + org.codehaus.mojo + exec-maven-plugin - test-native + run-native-smoke + verify - test + exec - test + + ${project.build.directory}/devcontainer-native-smoke + diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 0aca024..be6b3cf 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -4,11 +4,11 @@ */ module wtf.metio.devcontainer { - requires com.fasterxml.jackson.databind; + requires tools.jackson.databind; requires static io.soabase.recordbuilder.core; requires static java.compiler; - opens wtf.metio.devcontainer to com.fasterxml.jackson.databind; + opens wtf.metio.devcontainer to tools.jackson.databind; exports wtf.metio.devcontainer; diff --git a/src/main/java/wtf/metio/devcontainer/Command.java b/src/main/java/wtf/metio/devcontainer/Command.java index 9a13f96..6ff35e2 100644 --- a/src/main/java/wtf/metio/devcontainer/Command.java +++ b/src/main/java/wtf/metio/devcontainer/Command.java @@ -4,10 +4,10 @@ */ package wtf.metio.devcontainer; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.soabase.recordbuilder.core.RecordBuilder; import java.util.List; import java.util.Map; +import tools.jackson.databind.annotation.JsonDeserialize; /** * Wrapper for the various ways to specify commands in a devcontainer file. Note that only one of the parameters is set diff --git a/src/main/java/wtf/metio/devcontainer/CommandDeserializer.java b/src/main/java/wtf/metio/devcontainer/CommandDeserializer.java index d13bfd5..ecef659 100644 --- a/src/main/java/wtf/metio/devcontainer/CommandDeserializer.java +++ b/src/main/java/wtf/metio/devcontainer/CommandDeserializer.java @@ -4,22 +4,18 @@ */ package wtf.metio.devcontainer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.node.TextNode; - -import java.io.IOException; -import java.io.Serial; import java.util.List; import java.util.Map; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.node.StringNode; public final class CommandDeserializer extends StdDeserializer { - @Serial - private static final long serialVersionUID = -857648716461293606L; - public CommandDeserializer() { this(Command.class); } @@ -30,24 +26,16 @@ public CommandDeserializer(final Class vc) { @Override public Command deserialize(final JsonParser parser, final DeserializationContext context) - throws IOException { - final var node = parser.getCodec().readTree(parser); - if (node.isValueNode()) { - if (node instanceof TextNode textNode) { - return new Command(textNode.textValue(), null, null); - } + throws JacksonException { + final JsonNode node = context.readTree(parser); + if (node instanceof StringNode stringNode) { + return new Command(stringNode.stringValue(), null, null); } else if (node.isArray()) { - try (final var arrayNode = node.traverse(parser.getCodec())) { - final List list = arrayNode.readValueAs(new TypeReference>() { - }); - return new Command(null, list, null); - } + final JavaType type = context.getTypeFactory().constructCollectionType(List.class, String.class); + return new Command(null, context.readTreeAsValue(node, type), null); } else if (node.isObject()) { - try (final var objectNode = node.traverse(parser.getCodec())) { - final Map object = objectNode.readValueAs(new TypeReference>() { - }); - return new Command(null, null, object); - } + final JavaType type = context.getTypeFactory().constructMapType(Map.class, String.class, Command.class); + return new Command(null, null, context.readTreeAsValue(node, type)); } return context.reportInputMismatch(Command.class, "Cannot deserialize given input to Command"); diff --git a/src/main/java/wtf/metio/devcontainer/Devcontainer.java b/src/main/java/wtf/metio/devcontainer/Devcontainer.java index b6526ea..e301b1f 100644 --- a/src/main/java/wtf/metio/devcontainer/Devcontainer.java +++ b/src/main/java/wtf/metio/devcontainer/Devcontainer.java @@ -4,14 +4,14 @@ */ package wtf.metio.devcontainer; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import io.soabase.recordbuilder.core.RecordBuilder; import java.io.File; -import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.Map; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; /** * @param name A name for the dev container displayed in the UI. @@ -222,35 +222,35 @@ public record Devcontainer( WaitFor waitFor, HostRequirements hostRequirements) implements DevcontainerBuilder.With { - public static Devcontainer parse(final Path devcontainer) throws IOException { + public static Devcontainer parse(final Path devcontainer) { return parse(devcontainer, defaultObjectMapper()); } - public static Devcontainer parse(final Path devcontainer, final ObjectMapper objectMapper) throws IOException { - return objectMapper.readValue(devcontainer.toFile(), Devcontainer.class); + public static Devcontainer parse(final Path devcontainer, final ObjectMapper objectMapper) { + return objectMapper.readValue(devcontainer, Devcontainer.class); } - public static Devcontainer parse(final File devcontainer) throws IOException { + public static Devcontainer parse(final File devcontainer) { return parse(devcontainer, defaultObjectMapper()); } - public static Devcontainer parse(final File devcontainer, final ObjectMapper objectMapper) throws IOException { + public static Devcontainer parse(final File devcontainer, final ObjectMapper objectMapper) { return objectMapper.readValue(devcontainer, Devcontainer.class); } - public static Devcontainer parse(final String devcontainer) throws IOException { + public static Devcontainer parse(final String devcontainer) { return parse(devcontainer, defaultObjectMapper()); } - public static Devcontainer parse(final String devcontainer, final ObjectMapper objectMapper) throws IOException { + public static Devcontainer parse(final String devcontainer, final ObjectMapper objectMapper) { return objectMapper.readValue(devcontainer, Devcontainer.class); } public static ObjectMapper defaultObjectMapper() { - final var mapper = new ObjectMapper(); - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY); - return mapper; + return JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + .build(); } public static DevcontainerBuilder builder() { diff --git a/src/native/java/wtf/metio/devcontainer/NativeImageSmokeTest.java b/src/native/java/wtf/metio/devcontainer/NativeImageSmokeTest.java new file mode 100644 index 0000000..8debc40 --- /dev/null +++ b/src/native/java/wtf/metio/devcontainer/NativeImageSmokeTest.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: The devcontainer.java Authors + * SPDX-License-Identifier: 0BSD + */ +package wtf.metio.devcontainer; + +import java.util.List; + +/** + * Stand-in for a downstream consumer such as ilo: a minimal program that depends on the library and + * is compiled to a GraalVM native image by the {@code native} profile. It drives the + * reflection-backed JSON parsing and the generated builders, so a successful native build and run + * proves that the records and their {@code reflect-config.json} resolve under native-image. If this + * image builds and runs, any project depending on the library can native-compile it as well. + * + *

This class lives outside {@code src/main/java} and is only compiled when the {@code native} + * profile adds {@code src/native/java} as a source root, so it never ships in the published jar. + */ +public final class NativeImageSmokeTest { + + public static void main(final String[] args) throws Exception { + final var parsed = Devcontainer.parse(""" + { + "name": "smoke", + "image": "example:123", + "forwardPorts": [3000, "db:5432"], + "postCreateCommand": "npm install" + } + """); + require("example:123".equals(parsed.image()), "image"); + require(List.of("3000", "db:5432").equals(parsed.forwardPorts()), "forwardPorts"); + require("npm install".equals(parsed.postCreateCommand().string()), "postCreateCommand"); + + final var built = Devcontainer.builder().name("built").image("quay.io/x:1").create(); + require("built".equals(built.name()), "builder"); + require("renamed".equals(built.withName("renamed").name()), "wither"); + + System.out.println("native-image smoke check passed"); + } + + private static void require(final boolean condition, final String what) { + if (!condition) { + throw new AssertionError("native-image smoke check failed for: " + what); + } + } + +}