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,6 +60,13 @@ dependencies {
60 // the entry point because :distribution has no Kotlin sources of its own. 60 // the entry point because :distribution has no Kotlin sources of its own.
61 springBoot { 61 springBoot {
62 mainClass.set("org.vibeerp.platform.bootstrap.VibeErpApplicationKt") 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 // The fat-jar produced here is what the Dockerfile copies into the 72 // The fat-jar produced here is what the Dockerfile copies into the
gradle.properties
@@ -9,6 +9,16 @@ kotlin.code.style=official @@ -9,6 +9,16 @@ kotlin.code.style=official
9 kotlin.incremental=true 9 kotlin.incremental=true
10 10
11 # vibe_erp 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 vibeerp.api.version=1.0.0-SNAPSHOT 23 vibeerp.api.version=1.0.0-SNAPSHOT
14 vibeerp.group=org.vibeerp 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,6 +8,8 @@ import io.swagger.v3.oas.models.info.License
8 import io.swagger.v3.oas.models.security.SecurityRequirement 8 import io.swagger.v3.oas.models.security.SecurityRequirement
9 import io.swagger.v3.oas.models.security.SecurityScheme 9 import io.swagger.v3.oas.models.security.SecurityScheme
10 import io.swagger.v3.oas.models.servers.Server 10 import io.swagger.v3.oas.models.servers.Server
  11 +import org.springframework.beans.factory.ObjectProvider
  12 +import org.springframework.boot.info.BuildProperties
11 import org.springframework.context.annotation.Bean 13 import org.springframework.context.annotation.Bean
12 import org.springframework.context.annotation.Configuration 14 import org.springframework.context.annotation.Configuration
13 15
@@ -42,18 +44,25 @@ import org.springframework.context.annotation.Configuration @@ -42,18 +44,25 @@ import org.springframework.context.annotation.Configuration
42 * behind a reverse proxy. A future multi-environment story can 44 * behind a reverse proxy. A future multi-environment story can
43 * grow this list from configuration. 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 @Configuration 58 @Configuration
53 -class OpenApiConfiguration { 59 +class OpenApiConfiguration(
  60 + private val buildPropertiesProvider: ObjectProvider<BuildProperties>,
  61 +) {
54 62
55 @Bean 63 @Bean
56 fun vibeErpOpenApi(): OpenAPI { 64 fun vibeErpOpenApi(): OpenAPI {
  65 + val version = buildPropertiesProvider.ifAvailable?.version ?: FALLBACK_VERSION
57 val bearerScheme = SecurityScheme() 66 val bearerScheme = SecurityScheme()
58 .type(SecurityScheme.Type.HTTP) 67 .type(SecurityScheme.Type.HTTP)
59 .scheme("bearer") 68 .scheme("bearer")
@@ -67,7 +76,7 @@ class OpenApiConfiguration { @@ -67,7 +76,7 @@ class OpenApiConfiguration {
67 .info( 76 .info(
68 Info() 77 Info()
69 .title("vibe_erp") 78 .title("vibe_erp")
70 - .version(OPENAPI_INFO_VERSION) 79 + .version(version)
71 .description( 80 .description(
72 """ 81 """
73 Auto-generated OpenAPI 3 spec for the vibe_erp framework core and every 82 Auto-generated OpenAPI 3 spec for the vibe_erp framework core and every
@@ -127,10 +136,13 @@ class OpenApiConfiguration { @@ -127,10 +136,13 @@ class OpenApiConfiguration {
127 const val BEARER_SCHEME_NAME: String = "bearerAuth" 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 package org.vibeerp.platform.bootstrap.web 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 import org.springframework.web.bind.annotation.GetMapping 6 import org.springframework.web.bind.annotation.GetMapping
4 import org.springframework.web.bind.annotation.RequestMapping 7 import org.springframework.web.bind.annotation.RequestMapping
5 import org.springframework.web.bind.annotation.RestController 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 @RestController 38 @RestController
16 @RequestMapping("/api/v1/_meta") 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 @GetMapping("/info") 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 }