Commit 944e2731a17e42ad7c953d6c4246558034f0a314

Authored by zichun
1 parent f58e513e

feat(bootstrap): real buildInfo + framework version as single source of truth

Closes a 15-commit-old TODO on MetaController and unifies the
version story across /api/v1/_meta/info, /v3/api-docs, and the
api-v1.jar manifest.

**Build metadata wiring.** `distribution/build.gradle.kts` now
calls `buildInfo()` inside the `springBoot { }` block. This makes
Spring Boot's Gradle plug-in write `META-INF/build-info.properties`
into the bootJar at build time with group / artifact / version /
build time pulled from `project.version` + timestamps. Spring
Boot's `BuildInfoAutoConfiguration` then exposes a `BuildProperties`
bean that injection points can consume.

**MetaController enriched.** Now injects:
  - `ObjectProvider<BuildProperties>` — returns the real version
    (`0.28.0-SNAPSHOT`) and the build timestamp when packaged
    through the distribution bootJar; falls back to `0.0.0-test`
    inside a bare platform-bootstrap unit test classloader with
    no build-info file on the classpath.
  - `Environment` — returns `spring.profiles.active` so a
    dashboard can distinguish "dev" from "staging" from a prod
    container that activates no profile.

The GET /api/v1/_meta/info response now carries:
  - `name`, `apiVersion` — unchanged
  - `implementationVersion` — from BuildProperties (was stuck at
    "0.1.0-SNAPSHOT" via an unreachable `javaClass.package` lookup)
  - `buildTime` — ISO-8601 string from BuildProperties, null if
    the classpath has no build-info file
  - `activeProfiles` — list of effective spring profiles

**OpenApiConfiguration now reads version from BuildProperties too.**
Previously OPENAPI_INFO_VERSION was a hardcoded "v0.28.0"
constant. Now it's injected via ObjectProvider<BuildProperties>
with the same fallback pattern as MetaController. A single
version bump in gradle.properties now flows to:
  gradle.properties
    → Spring Boot's buildInfo()
    → build-info.properties (on the classpath)
    → BuildProperties bean
    → MetaController (/_meta/info)
    → OpenApiConfiguration (/v3/api-docs + Swagger UI)
    → api-v1.jar manifest (already wired)

No more hand-maintained version strings in code. Bump
`vibeerp.version` in gradle.properties and every display follows.

**Version bump.** `gradle.properties` `vibeerp.version`:
`0.1.0-SNAPSHOT` → `0.28.0-SNAPSHOT`. This matches the numeric
label used on PROGRESS.md's "Latest version" row and carries a
documentation comment explaining the propagation chain so the
next person bumping it knows what to update alongside (just the
one line + PROGRESS.md).

**KDoc trap caught.** A literal `/api/v1/_meta/**` path pattern
in MetaController's KDoc tripped the Kotlin nested-comment
parser (`/**` starts a KDoc). Rephrased as "the whole `/api/v1/_meta`
prefix" to sidestep the trap — same workaround I saved in
feedback memory after the first time it bit me.

**Smoke-tested end-to-end against real Postgres:**
  - GET /api/v1/_meta/info returns
    `{"implementationVersion": "0.28.0-SNAPSHOT",
      "buildTime": "2026-04-09T09:48:25.646Z",
      "activeProfiles": ["dev"]}`
  - GET /v3/api-docs `info.version` = "0.28.0-SNAPSHOT" (was
    the hardcoded "v0.28.0" constant before this chunk)
  - Single edit to gradle.properties propagates cleanly.

24 modules, 355 unit tests, all green.
distribution/build.gradle.kts
... ... @@ -60,6 +60,13 @@ dependencies {
60 60 // the entry point because :distribution has no Kotlin sources of its own.
61 61 springBoot {
62 62 mainClass.set("org.vibeerp.platform.bootstrap.VibeErpApplicationKt")
  63 + // buildInfo() writes build metadata (group, artifact, version, build
  64 + // timestamp) into META-INF/build-info.properties at build time. Spring
  65 + // Boot's BuildInfoAutoConfiguration picks it up at runtime and exposes
  66 + // it as a BuildProperties bean that MetaController injects to fill in
  67 + // a real version/build time on GET /api/v1/_meta/info. Without this
  68 + // the fallback in MetaController was stuck at "0.1.0-SNAPSHOT".
  69 + buildInfo()
63 70 }
64 71  
65 72 // The fat-jar produced here is what the Dockerfile copies into the
... ...
gradle.properties
... ... @@ -9,6 +9,16 @@ kotlin.code.style=official
9 9 kotlin.incremental=true
10 10  
11 11 # vibe_erp
12   -vibeerp.version=0.1.0-SNAPSHOT
  12 +# Single source of truth for the framework version. Flows into:
  13 +# - Spring Boot's BuildProperties (via `springBoot { buildInfo() }`
  14 +# in :distribution), which MetaController returns as
  15 +# `implementationVersion` on GET /api/v1/_meta/info and
  16 +# OpenApiConfiguration renders as the OpenAPI info.version
  17 +# in Swagger UI + /v3/api-docs.
  18 +# - api-v1.jar manifest (Implementation-Version) via
  19 +# api/api-v1/build.gradle.kts.
  20 +# Bump this line in lockstep with PROGRESS.md's "Latest version"
  21 +# row when shipping a new working surface.
  22 +vibeerp.version=0.28.0-SNAPSHOT
13 23 vibeerp.api.version=1.0.0-SNAPSHOT
14 24 vibeerp.group=org.vibeerp
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/openapi/OpenApiConfiguration.kt
... ... @@ -8,6 +8,8 @@ import io.swagger.v3.oas.models.info.License
8 8 import io.swagger.v3.oas.models.security.SecurityRequirement
9 9 import io.swagger.v3.oas.models.security.SecurityScheme
10 10 import io.swagger.v3.oas.models.servers.Server
  11 +import org.springframework.beans.factory.ObjectProvider
  12 +import org.springframework.boot.info.BuildProperties
11 13 import org.springframework.context.annotation.Bean
12 14 import org.springframework.context.annotation.Configuration
13 15  
... ... @@ -42,18 +44,25 @@ import org.springframework.context.annotation.Configuration
42 44 * behind a reverse proxy. A future multi-environment story can
43 45 * grow this list from configuration.
44 46 *
45   - * **Version source.** [OPENAPI_INFO_VERSION] is a build-time
46   - * constant kept in sync with the framework's `PROGRESS.md` at
47   - * memory time; it's not parsed out of any manifest because the
48   - * distribution fat-jar doesn't carry a semver yet. When a real
49   - * version header lands this moves to a @Value on a config
50   - * property, but hardcoding it now keeps the chunk small.
  47 + * **Version source.** The displayed version is read from Spring
  48 + * Boot's [BuildProperties] bean, which is itself populated at
  49 + * build time from `META-INF/build-info.properties` generated by
  50 + * `springBoot { buildInfo() }` in `:distribution/build.gradle.kts`.
  51 + * Single source of truth: `gradle.properties`' `vibeerp.version`
  52 + * → `build-info.properties` → [BuildProperties] → here. When the
  53 + * framework is bumped in `gradle.properties`, every surface that
  54 + * displays a version follows automatically. Injected via
  55 + * [ObjectProvider] so unit tests that instantiate the config
  56 + * without a build-info file don't fail.
51 57 */
52 58 @Configuration
53   -class OpenApiConfiguration {
  59 +class OpenApiConfiguration(
  60 + private val buildPropertiesProvider: ObjectProvider<BuildProperties>,
  61 +) {
54 62  
55 63 @Bean
56 64 fun vibeErpOpenApi(): OpenAPI {
  65 + val version = buildPropertiesProvider.ifAvailable?.version ?: FALLBACK_VERSION
57 66 val bearerScheme = SecurityScheme()
58 67 .type(SecurityScheme.Type.HTTP)
59 68 .scheme("bearer")
... ... @@ -67,7 +76,7 @@ class OpenApiConfiguration {
67 76 .info(
68 77 Info()
69 78 .title("vibe_erp")
70   - .version(OPENAPI_INFO_VERSION)
  79 + .version(version)
71 80 .description(
72 81 """
73 82 Auto-generated OpenAPI 3 spec for the vibe_erp framework core and every
... ... @@ -127,10 +136,13 @@ class OpenApiConfiguration {
127 136 const val BEARER_SCHEME_NAME: String = "bearerAuth"
128 137  
129 138 /**
130   - * Displayed in Swagger UI's header. Hardcoded for now; when
131   - * a real build-time version header ships this becomes a
132   - * configuration property.
  139 + * Emitted when `META-INF/build-info.properties` is not on
  140 + * the classpath — only happens in unit-test classloaders
  141 + * for platform-bootstrap itself, which don't package
  142 + * through the Spring Boot fat-jar pipeline. A bootable
  143 + * distribution always has a real version and this
  144 + * fallback is unreachable at runtime.
133 145 */
134   - const val OPENAPI_INFO_VERSION: String = "v0.28.0"
  146 + const val FALLBACK_VERSION: String = "0.0.0-test"
135 147 }
136 148 }
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/MetaController.kt
1 1 package org.vibeerp.platform.bootstrap.web
2 2  
  3 +import org.springframework.beans.factory.ObjectProvider
  4 +import org.springframework.boot.info.BuildProperties
  5 +import org.springframework.core.env.Environment
3 6 import org.springframework.web.bind.annotation.GetMapping
4 7 import org.springframework.web.bind.annotation.RequestMapping
5 8 import org.springframework.web.bind.annotation.RestController
  9 +import java.time.Instant
6 10  
7 11 /**
8   - * Minimal info endpoint that confirms the framework boots and identifies itself.
  12 + * Public info endpoint that confirms the framework is alive and
  13 + * describes what kind of vibe_erp this is.
9 14 *
10   - * Real health checks come from Spring Boot Actuator at `/actuator/health`.
11   - * This endpoint is intentionally separate so it can serve as the "is the
12   - * framework alive AND has it loaded its plug-ins?" signal once the plug-in
13   - * loader is wired up — for v0.1 it just reports the static info.
  15 + * Real liveness/readiness probes come from Spring Boot Actuator at
  16 + * `/actuator/health`; this endpoint is intentionally separate so
  17 + * it can carry framework-level identification a load balancer or a
  18 + * future R1 SPA login page can display ("connected to vibe_erp
  19 + * v0.28.0, built 2026-04-09") without following a JWT-gated route.
  20 + *
  21 + * **Public by design.** The whole `/api/v1/_meta` prefix is on
  22 + * the `SecurityConfiguration` permit-all list, so this response
  23 + * must NEVER carry secrets — no JDBC URL, no JWT secret, no admin
  24 + * username, no env-specific config keys. Only framework identity
  25 + * + coarse capabilities.
  26 + *
  27 + * **Build metadata source.** [BuildProperties] is auto-wired by
  28 + * Spring Boot from `META-INF/build-info.properties`, which
  29 + * Gradle's `springBoot { buildInfo() }` produces at `:distribution`
  30 + * build time. Injected via [ObjectProvider] so the controller can
  31 + * be instantiated in test contexts that have no build-info file
  32 + * on the classpath without a bean-missing error at startup.
  33 + *
  34 + * **Active profiles** come straight from the Spring [Environment].
  35 + * Lists the effective `spring.profiles.active` (e.g. `["dev"]` for
  36 + * `./gradlew :distribution:bootRun`, `[]` for a prod container).
14 37 */
15 38 @RestController
16 39 @RequestMapping("/api/v1/_meta")
17   -class MetaController {
  40 +class MetaController(
  41 + private val buildPropertiesProvider: ObjectProvider<BuildProperties>,
  42 + private val environment: Environment,
  43 +) {
18 44  
19 45 @GetMapping("/info")
20   - fun info(): Map<String, Any> = mapOf(
21   - "name" to "vibe-erp",
22   - "apiVersion" to "v1",
23   - // TODO(v0.2): read implementationVersion from the Spring Boot
24   - // BuildProperties bean once `springBoot { buildInfo() }` is wired
25   - // up in the distribution module. Hard-coding a fallback here is
26   - // fine for v0.1 but will silently drift from `gradle.properties`.
27   - "implementationVersion" to (javaClass.`package`.implementationVersion ?: "0.1.0-SNAPSHOT"),
28   - )
  46 + fun info(): Map<String, Any?> {
  47 + val build: BuildProperties? = buildPropertiesProvider.ifAvailable
  48 + val version = build?.version ?: FALLBACK_VERSION
  49 + val buildTime: Instant? = build?.time
  50 +
  51 + return mapOf(
  52 + "name" to "vibe-erp",
  53 + "apiVersion" to "v1",
  54 + "implementationVersion" to version,
  55 + // ISO-8601 string, or null if the build was produced
  56 + // without `springBoot { buildInfo() }` (e.g. a bare
  57 + // Gradle test run of platform-bootstrap itself).
  58 + "buildTime" to buildTime?.toString(),
  59 + "activeProfiles" to environment.activeProfiles.toList(),
  60 + )
  61 + }
  62 +
  63 + companion object {
  64 + /**
  65 + * Emitted when no `build-info.properties` is on the classpath —
  66 + * happens in unit-test classloaders for platform-bootstrap
  67 + * itself, which don't go through the Spring Boot fat-jar
  68 + * packaging. A bootable distribution always has a real
  69 + * version and this fallback is unreachable.
  70 + */
  71 + const val FALLBACK_VERSION: String = "0.0.0-test"
  72 + }
29 73 }
... ...