From 00195b78e1557084e5ac22c962ae8f7e6d90b283 Mon Sep 17 00:00:00 2001 From: keep simple <3132670669@qq.com> Date: Sat, 12 Sep 2026 19:39:44 +0800 Subject: [PATCH] fix(core): derive Version.VERSION from Maven project version instead of hardcoding Fixes #3086 Resolve the version at build time instead of keeping a hardcoded constant that drifts from the pom: - agentscope-core pom: filter only META-INF/agentscope/version.properties (${project.version}); the unfiltered catch-all resource block excludes it so the filtered block is the sole writer of the target file. This removes the fragile declaration-order race where the unfiltered copy (running first) rewrites the target with a newer timestamp and makes maven-resources-plugin skip the filtered copy, silently packaging the literal placeholder. - agentscope-core pom: add a maven-antrun build check (process-classes phase) that fails loudly when the filtered resource still contains an unmapped placeholder. - parent pom: enable maven-jar-plugin addDefaultImplementationEntries so module jars record Implementation-Version, used as the fallback when the classpath resource is missing (shaded/relocated or custom packaging). - Version: three-level fallback chain - filtered classpath resource, jar manifest Implementation-Version, then "unknown" with a one-time warning log; blank and unfiltered ${...} values are rejected. - VersionTest: skip only when the version resource is absent from the classpath (IDE/non-Maven run); an unfiltered value present on the classpath is now a hard failure; adds manifest/resource/IO branch tests. - version.properties files: add trailing newline. Verified locally: mvn process-classes and VersionTest 21/21 green; the packaged jar manifest contains Implementation-Version: 2.0.3-SNAPSHOT. --- agentscope-core/pom.xml | 70 ++++++++ .../main/java/io/agentscope/core/Version.java | 132 +++++++++++++- .../META-INF/agentscope/version.properties | 1 + .../java/io/agentscope/core/VersionTest.java | 164 +++++++++++++++++- .../agentscope-test-version.properties | 1 + pom.xml | 17 ++ 6 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 agentscope-core/src/main/resources/META-INF/agentscope/version.properties create mode 100644 agentscope-core/src/test/resources/agentscope-test-version.properties diff --git a/agentscope-core/pom.xml b/agentscope-core/pom.xml index 04acf4014d..5d02d2ca3e 100644 --- a/agentscope-core/pom.xml +++ b/agentscope-core/pom.xml @@ -206,7 +206,77 @@ + + + + src/main/resources + false + + META-INF/agentscope/version.properties + + + + + src/main/resources + true + + META-INF/agentscope/version.properties + + + + + + src/test/resources + false + + agentscope-test-version.properties + + + + src/test/resources + true + + agentscope-test-version.properties + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.2.0 + + + verify-version-resource-filtered + process-classes + + run + + + + + + + + + + + + + + diff --git a/agentscope-core/src/main/java/io/agentscope/core/Version.java b/agentscope-core/src/main/java/io/agentscope/core/Version.java index bf41a18ad9..38adaef191 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/Version.java +++ b/agentscope-core/src/main/java/io/agentscope/core/Version.java @@ -15,21 +15,149 @@ */ package io.agentscope.core; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * AgentScope version and User-Agent information. * *

Provides a unified User-Agent string for all model requests to identify AgentScope Java * clients and collect usage statistics. + * + *

The version is resolved at class-load time through a three-level fallback chain: + * + *

    + *
  1. the {@code META-INF/agentscope/version.properties} classpath resource, whose + * {@code ${project.version}} placeholder is resolved by Maven resource filtering; + *
  2. the {@code Implementation-Version} manifest entry of the containing jar, which is + * populated from {@code ${project.version}} by the Maven jar plugin; + *
  3. the literal {@code "unknown"} as a last resort (e.g. running straight from IDE + * {@code target/classes} with no manifest), accompanied by a one-time warning log. + *
*/ public final class Version { - /** AgentScope Java version */ - public static final String VERSION = "1.0.13-SNAPSHOT"; + private static final Logger log = LoggerFactory.getLogger(Version.class); + + private static final String VERSION_RESOURCE = "/META-INF/agentscope/version.properties"; + + /** Sentinel returned when no build-resolved version can be discovered. */ + static final String UNKNOWN = "unknown"; + + /** + * AgentScope Java version. + * + *

Injected at build time by Maven resource filtering from {@code ${project.version}}; + * see the class Javadoc for the fallback chain. + */ + public static final String VERSION = resolveVersion(); private Version() { // Utility class - prevent instantiation } + private static String resolveVersion() { + // 1) Maven-filtered classpath resource (normal Maven builds, tests). + String fromResource; + try (InputStream in = Version.class.getResourceAsStream(VERSION_RESOURCE)) { + fromResource = resolveVersionFromResource(in); + } catch (IOException e) { + log.debug("Failed to read {} from the classpath", VERSION_RESOURCE, e); + fromResource = UNKNOWN; + } + if (!UNKNOWN.equals(fromResource)) { + return fromResource; + } + + // 2) jar manifest Implementation-Version (packaged jars whose META-INF/agentscope + // directory may have been dropped, e.g. by shading or custom packaging). + String fromManifest = resolveVersionFromManifest(getImplementationVersion()); + if (!UNKNOWN.equals(fromManifest)) { + log.debug( + "Resolved version from jar manifest Implementation-Version: {}", fromManifest); + return fromManifest; + } + + // 3) Last resort: make the failure visible instead of silently using a stale value. + log.warn( + "Could not resolve the AgentScope version from {} or the jar manifest " + + "Implementation-Version; reporting '{}'. Run against a Maven-built " + + "classpath for accurate versioning.", + VERSION_RESOURCE, + UNKNOWN); + return UNKNOWN; + } + + /** + * Load and resolve the version from the classpath resource stream. + * + *

Package-private for unit testing. + * + * @param in stream to {@code version.properties}, or {@code null} when the resource is absent + * @return the resolved version, or {@code "unknown"} when the stream is {@code null} or cannot + * be read + */ + static String resolveVersionFromResource(InputStream in) { + if (in == null) { + return UNKNOWN; + } + try (in) { + Properties props = new Properties(); + props.load(in); + return resolveVersionFrom(props); + } catch (IOException e) { + log.debug("Failed to read {}", VERSION_RESOURCE, e); + return UNKNOWN; + } + } + + /** + * Resolve the version string from a properties object, guarding against missing, blank, + * and unfiltered ({@code ${...}}) values. + * + *

Package-private for unit testing. + * + * @param props properties loaded from the version resource + * @return trimmed version, or {@code "unknown"} when absent, blank, or unfiltered + */ + static String resolveVersionFrom(Properties props) { + if (props == null) { + return UNKNOWN; + } + String version = props.getProperty("version"); + // Guard against an unfiltered ${project.version} literal, e.g. when running + // from an IDE that does not run Maven resource filtering. + if (version != null && !version.isBlank() && !version.startsWith("${")) { + return version.trim(); + } + return UNKNOWN; + } + + /** + * Resolve the version from the jar manifest's {@code Implementation-Version} entry. + * + *

Package-private for unit testing. + * + * @param implementationVersion manifest value, or {@code null} when absent + * @return the resolved version, or {@code "unknown"} when absent, blank, or unfiltered + */ + static String resolveVersionFromManifest(String implementationVersion) { + if (implementationVersion != null + && !implementationVersion.isBlank() + && !implementationVersion.startsWith("${")) { + return implementationVersion.trim(); + } + return UNKNOWN; + } + + private static String getImplementationVersion() { + Package pkg = Version.class.getPackage(); + return pkg == null ? null : pkg.getImplementationVersion(); + } + /** * Generate standard User-Agent string for all models. * diff --git a/agentscope-core/src/main/resources/META-INF/agentscope/version.properties b/agentscope-core/src/main/resources/META-INF/agentscope/version.properties new file mode 100644 index 0000000000..defbd48204 --- /dev/null +++ b/agentscope-core/src/main/resources/META-INF/agentscope/version.properties @@ -0,0 +1 @@ +version=${project.version} diff --git a/agentscope-core/src/test/java/io/agentscope/core/VersionTest.java b/agentscope-core/src/test/java/io/agentscope/core/VersionTest.java index 6909538e4f..acf7bb6ab3 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/VersionTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/VersionTest.java @@ -15,23 +15,175 @@ */ package io.agentscope.core; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; /** * Unit tests for {@link Version} class. * - *

Verifies User-Agent string generation for identifying AgentScope Java clients. + *

Verifies the build-resolved version and User-Agent string generation for identifying + * AgentScope Java clients. */ class VersionTest { + private static final String MAIN_VERSION_RESOURCE = "/META-INF/agentscope/version.properties"; + private static final String TEST_VERSION_RESOURCE = "/agentscope-test-version.properties"; + private static final String SEMVER = "\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?"; + + private static Properties loadProps(String resourcePath) throws IOException { + try (InputStream in = VersionTest.class.getResourceAsStream(resourcePath)) { + if (in == null) { + return null; + } + Properties props = new Properties(); + props.load(in); + return props; + } + } + + @Test + void testVersionConstant() throws IOException { + Properties props = loadProps(MAIN_VERSION_RESOURCE); + // The resource is absent only when the build did not run Maven resource processing at + // all (e.g. an IDE run against raw target/classes without resources), so there is no + // expected value to cross-check - skip, not fail. + if (props == null) { + Assumptions.abort( + MAIN_VERSION_RESOURCE + + " is missing from the classpath (non-Maven/IDE run);" + + " skipping strict version cross-check"); + } + String expectedVersion = props.getProperty("version"); + Assertions.assertNotNull(expectedVersion, "version property should be present"); + + // If the resource IS present but still contains the unfiltered ${project.version} + // placeholder, Maven resource filtering genuinely did not apply. That is a loud + // failure, not an assumption violation - a packaged jar would silently carry the + // placeholder while Version.resolveVersionFrom masks it as "unknown". + Assertions.assertFalse( + expectedVersion.contains("${"), + MAIN_VERSION_RESOURCE + + " is unfiltered (still contains '${'): Maven resource filtering" + + " did not apply"); + + // Strict cross-check: the runtime version must match the Maven project version exactly. + Assertions.assertEquals( + expectedVersion, Version.VERSION, "VERSION must match the Maven project version"); + + // Semantic version format check. + Assertions.assertTrue( + Version.VERSION.matches(SEMVER), + "VERSION should be a valid semver: " + Version.VERSION); + } + + @Test + void testVersionConstant_CrossCheckTestResource() throws IOException { + Properties props = loadProps(TEST_VERSION_RESOURCE); + if (props == null) { + Assumptions.abort( + TEST_VERSION_RESOURCE + + " is missing from the classpath (non-Maven/IDE run);" + + " skipping cross-check"); + } + String expectedVersion = props.getProperty("version"); + Assertions.assertNotNull(expectedVersion, "version property should be present"); + Assertions.assertFalse( + expectedVersion.contains("${"), "test version.properties is unfiltered"); + Assertions.assertEquals( + expectedVersion, + Version.VERSION, + "test-resource version must match the runtime VERSION"); + } + + @Test + void testResolveVersionFromResource_Normal() throws IOException { + String content = "version=2.0.3-SNAPSHOT"; + Assertions.assertEquals( + "2.0.3-SNAPSHOT", + Version.resolveVersionFromResource( + new java.io.ByteArrayInputStream( + content.getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } + + @Test + void testResolveVersionFromResource_NullStream() { + Assertions.assertEquals(Version.UNKNOWN, Version.resolveVersionFromResource(null)); + } + + @Test + void testResolveVersionFromResource_UnreadableStream() { + InputStream broken = + new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("boom"); + } + }; + Assertions.assertEquals(Version.UNKNOWN, Version.resolveVersionFromResource(broken)); + } + + @Test + void testResolveVersionFrom_Normal() { + Properties props = new Properties(); + props.setProperty("version", " 2.0.3-SNAPSHOT "); + Assertions.assertEquals("2.0.3-SNAPSHOT", Version.resolveVersionFrom(props)); + } + + @Test + void testResolveVersionFrom_UnfilteredPlaceholder() { + Properties props = new Properties(); + props.setProperty("version", "${project.version}"); + Assertions.assertEquals(Version.UNKNOWN, Version.resolveVersionFrom(props)); + } + + @Test + void testResolveVersionFrom_NullValue() { + Properties props = new Properties(); + Assertions.assertEquals(Version.UNKNOWN, Version.resolveVersionFrom(props)); + } + + @Test + void testResolveVersionFrom_BlankValue() { + Properties props = new Properties(); + props.setProperty("version", " "); + Assertions.assertEquals(Version.UNKNOWN, Version.resolveVersionFrom(props)); + } + + @Test + void testResolveVersionFrom_NullProps() { + Assertions.assertEquals(Version.UNKNOWN, Version.resolveVersionFrom(null)); + } + + @Test + void testResolveVersionFromManifest_Normal() { + Assertions.assertEquals( + "2.0.3-SNAPSHOT", Version.resolveVersionFromManifest("2.0.3-SNAPSHOT")); + } + + @Test + void testResolveVersionFromManifest_Trimmed() { + Assertions.assertEquals( + "2.0.3-SNAPSHOT", Version.resolveVersionFromManifest(" 2.0.3-SNAPSHOT ")); + } + + @Test + void testResolveVersionFromManifest_Null() { + Assertions.assertEquals(Version.UNKNOWN, Version.resolveVersionFromManifest(null)); + } + + @Test + void testResolveVersionFromManifest_Blank() { + Assertions.assertEquals(Version.UNKNOWN, Version.resolveVersionFromManifest(" ")); + } + @Test - void testVersionConstant() { - // Verify version constant is set - Assertions.assertNotNull(Version.VERSION, "VERSION constant should not be null"); - Assertions.assertFalse(Version.VERSION.isEmpty(), "VERSION constant should not be empty"); + void testResolveVersionFromManifest_UnfilteredPlaceholder() { Assertions.assertEquals( - "1.0.13-SNAPSHOT", Version.VERSION, "VERSION should match current version"); + Version.UNKNOWN, Version.resolveVersionFromManifest("${project.version}")); } @Test diff --git a/agentscope-core/src/test/resources/agentscope-test-version.properties b/agentscope-core/src/test/resources/agentscope-test-version.properties new file mode 100644 index 0000000000..defbd48204 --- /dev/null +++ b/agentscope-core/src/test/resources/agentscope-test-version.properties @@ -0,0 +1 @@ +version=${project.version} diff --git a/pom.xml b/pom.xml index 9cbe8129fc..6782feae61 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,7 @@ 3.15.0 3.5.5 3.1.4 + 3.4.2 3.4.0 3.12.0 3.6.2 @@ -332,6 +333,22 @@ + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + + true + + + + org.apache.maven.plugins maven-deploy-plugin