1+ #! /usr/bin/env groovy
2+ /*
3+ * Agent Config Schema Version Bump — release-time entry point.
4+ *
5+ * Compares the schema at a prior git ref (typically the last agent release
6+ * tag) to the current on-disk schema, classifies the diff, and bumps the
7+ * version in .fleetControl/configurationDefinitions.yml accordingly.
8+ *
9+ * Usage:
10+ * groovy BumpSchemaVersion.groovy --since=<ref> # dry-run
11+ * groovy BumpSchemaVersion.groovy --since=<ref> --ci # write
12+ *
13+ * Exit codes:
14+ * 0 — no bump needed (no schema diff, or bootstrap case where the ref
15+ * predates the .fleetControl/ system)
16+ * 1 — bump applied (with --ci) or recommended (without --ci)
17+ * 2 — generator failure (uncaught exception, missing args, etc.)
18+ *
19+ * The bump kind (major/minor/patch) is determined by the cumulative schema
20+ * diff between <ref> and HEAD. See SchemaDiff.groovy for classification rules.
21+ *
22+ * The schema file path is read from configurationDefinitions.yml's `schema`
23+ * field at both the historical ref and HEAD, so renames or relocations of
24+ * the schema file don't break the bump path.
25+ */
26+
27+ @Grab (' org.yaml:snakeyaml:2.2' )
28+ import org.yaml.snakeyaml.Yaml
29+ import groovy.json.JsonSlurper
30+
31+ // ---------------------------------------------------------------------------
32+ // Paths
33+ // ---------------------------------------------------------------------------
34+ final File SCRIPT_DIR = new File (getClass(). protectionDomain. codeSource. location. toURI()). parentFile ?: new File (' .' )
35+ final File FLEET_CONTROL_DIR = SCRIPT_DIR . parentFile
36+ final File REPO_ROOT = FLEET_CONTROL_DIR . parentFile
37+ final File CONFIG_DEF_PATH = new File (FLEET_CONTROL_DIR , ' configurationDefinitions.yml' )
38+
39+ // Path to configurationDefinitions.yml relative to repo root — used for `git show`.
40+ final String CONFIG_DEF_REPO_PATH = ' .fleetControl/configurationDefinitions.yml'
41+
42+ // ---------------------------------------------------------------------------
43+ // Helpers
44+ // ---------------------------------------------------------------------------
45+
46+ /**
47+ * Run `git show <ref>:<path>` and return the file contents, or null if the
48+ * file doesn't exist at that ref. Bootstrapping pre-system tags relies on
49+ * this returning null.
50+ */
51+ static String gitShow (File repoRoot , String ref , String path ) {
52+ Process p = [' git' , ' -C' , repoRoot. absolutePath, ' show' , " ${ ref} :${ path} " ]. execute()
53+ StringBuilder out = new StringBuilder ()
54+ StringBuilder err = new StringBuilder ()
55+ p. waitForProcessOutput(out, err)
56+ if (p. exitValue() != 0 ) {
57+ return null // file doesn't exist at ref, or ref is invalid
58+ }
59+ return out. toString()
60+ }
61+
62+ /**
63+ * Parse configurationDefinitions.yml text → [version, schemaPath].
64+ *
65+ * If `strict` is true, missing fields throw. If false, missing fields return
66+ * null in the corresponding slot — used for the historical parse so a tag
67+ * with an incomplete configurationDefinitions.yml (e.g., a placeholder that
68+ * predates the schema work) maps to "bootstrap, no bump" rather than a
69+ * generator failure.
70+ */
71+ static List<String > parseDefinitions (String yamlText , String contextLabel , boolean strict = true ) {
72+ Map<String , Object > data = (Map<String , Object > ) new Yaml (). load(yamlText)
73+ List defs = (List ) (data?. get(' configurationDefinitions' ) ?: [])
74+ if (defs. isEmpty()) {
75+ if (strict) throw new IllegalStateException (" ${ contextLabel} : configurationDefinitions list is empty" )
76+ return [null , null ]
77+ }
78+ Map<String , Object > def0 = (Map<String , Object > ) defs[0 ]
79+ String version = def0. get(' version' )?. toString()
80+ String schemaPath = def0. get(' schema' )?. toString()
81+ if (strict) {
82+ if (! version) throw new IllegalStateException (" ${ contextLabel} : version missing" )
83+ if (! schemaPath) throw new IllegalStateException (" ${ contextLabel} : schema missing" )
84+ }
85+ return [version, schemaPath]
86+ }
87+
88+ /**
89+ * Resolve the schema path declared in configurationDefinitions.yml to a
90+ * repo-rooted path string suitable for `git show`. The yaml's `schema:`
91+ * field is relative to configurationDefinitions.yml's location.
92+ */
93+ static String resolveSchemaRepoPath (String configDefRepoPath , String schemaRelative ) {
94+ File configDir = new File (configDefRepoPath). parentFile
95+ File resolved = new File (configDir, schemaRelative). canonicalFile
96+ File cwd = new File (' .' ). canonicalFile
97+ String rel = cwd. toPath(). relativize(resolved. toPath()). toString()
98+ // git show expects forward slashes; normalize on Windows in case.
99+ return rel. replace(' \\ ' , ' /' )
100+ }
101+
102+ // ---------------------------------------------------------------------------
103+ // Main — wrapped in try/catch so uncaught exceptions exit 2
104+ // ---------------------------------------------------------------------------
105+ if (binding. variables. get(' TEST_MODE' ) != true ) {
106+ try {
107+ // Parse CLI flags
108+ String sinceRef = null
109+ boolean ciMode = false
110+ List<String > rawArgs = (binding. variables. get(' args' ) ?: []) as List<String >
111+ rawArgs. each { String arg ->
112+ if (arg. startsWith(' --since=' )) {
113+ sinceRef = arg. substring(' --since=' . length())
114+ } else if (arg == ' --ci' ) {
115+ ciMode = true
116+ } else {
117+ System . err. println " Unknown flag: ${ arg} "
118+ System . err. println " Usage: groovy BumpSchemaVersion.groovy --since=<ref> [--ci]"
119+ System . exit(2 )
120+ }
121+ }
122+ if (! sinceRef) {
123+ System . err. println " Missing required flag: --since=<ref>"
124+ System . err. println " Usage: groovy BumpSchemaVersion.groovy --since=<ref> [--ci]"
125+ System . exit(2 )
126+ }
127+
128+ // Load the SchemaDiff library
129+ File schemaDiffFile = new File (SCRIPT_DIR , ' SchemaDiff.groovy' )
130+ GroovyShell shell = new GroovyShell ()
131+ Script lib = shell. parse(schemaDiffFile)
132+ lib. run()
133+
134+ println " Comparing schema at ${ sinceRef} to current HEAD"
135+
136+ // Step 1: fetch historical configurationDefinitions.yml
137+ String historicalDefsText = gitShow(REPO_ROOT , sinceRef, CONFIG_DEF_REPO_PATH )
138+ if (historicalDefsText == null ) {
139+ // Bootstrap case: the ref predates the .fleetControl/ system.
140+ // Treat as first release of the schema — no bump, no PR.
141+ println " configurationDefinitions.yml does not exist at ${ sinceRef} ."
142+ println " Treating this as the first release that includes the schema; no bump applied."
143+ System . exit(0 )
144+ }
145+
146+ def (String starterVersion, String historicalSchemaRel) = parseDefinitions(
147+ historicalDefsText, " configurationDefinitions.yml@${ sinceRef} " , false )
148+ if (! starterVersion || ! historicalSchemaRel) {
149+ // Bootstrap: file existed at the ref but didn't have the fields we need.
150+ // Common when a placeholder configurationDefinitions.yml was committed
151+ // before the schema work was complete.
152+ println " configurationDefinitions.yml at ${ sinceRef} is incomplete (missing version or schema field)."
153+ println " Treating this as the first release that includes the schema; no bump applied."
154+ System . exit(0 )
155+ }
156+ println " Starter version (from ${ sinceRef} ): ${ starterVersion} "
157+
158+ // Step 2: fetch historical schema using its declared path
159+ String historicalSchemaPath = resolveSchemaRepoPath(CONFIG_DEF_REPO_PATH , historicalSchemaRel)
160+ String historicalSchemaText = gitShow(REPO_ROOT , sinceRef, historicalSchemaPath)
161+ if (historicalSchemaText == null ) {
162+ // configurationDefinitions.yml existed but pointed at a schema file
163+ // that doesn't. Treat as bootstrap — same as missing definitions.
164+ println " Schema file at ${ historicalSchemaPath} does not exist at ${ sinceRef} ."
165+ println " Treating this as the first release that includes the schema; no bump applied."
166+ System . exit(0 )
167+ }
168+ Map<String , Object > historicalSchema = (Map<String , Object > ) new JsonSlurper (). parseText(historicalSchemaText)
169+
170+ // Step 3: read current schema using its declared path (handles renames)
171+ if (! CONFIG_DEF_PATH . exists()) {
172+ throw new FileNotFoundException (" Current configurationDefinitions.yml not found at ${ CONFIG_DEF_PATH} " )
173+ }
174+ def (String currentVersion, String currentSchemaRel) = parseDefinitions(
175+ CONFIG_DEF_PATH . text, " configurationDefinitions.yml (current)" )
176+ File currentSchemaFile = new File (CONFIG_DEF_PATH . parentFile, currentSchemaRel). canonicalFile
177+ if (! currentSchemaFile. exists()) {
178+ throw new FileNotFoundException (" Current schema not found at ${ currentSchemaFile} " )
179+ }
180+ Map<String , Object > currentSchema = (Map<String , Object > ) new JsonSlurper (). parse(currentSchemaFile)
181+
182+ // Step 4: classify diff and recommend a bump
183+ List<Map<String , Object > > changes = lib. classifyChanges(historicalSchema, currentSchema)
184+ if (changes) {
185+ List<Map<String , Object > > breaking = changes. findAll { it. get(' severity' ) == ' breaking' }
186+ List<Map<String , Object > > additive = changes. findAll { it. get(' severity' ) == ' additive' }
187+ List<Map<String , Object > > cosmetic = changes. findAll { it. get(' severity' ) == ' cosmetic' }
188+ println " \n Schema changes since ${ sinceRef} (${ changes.size()} ):"
189+ if (breaking) {
190+ println " BREAKING (${ breaking.size()} ):"
191+ breaking. each { Map<String , Object > ch -> println " ${ lib.renderChange(ch)} " }
192+ }
193+ if (additive) {
194+ println " ADDITIVE (${ additive.size()} ):"
195+ additive. each { Map<String , Object > ch -> println " ${ lib.renderChange(ch)} " }
196+ }
197+ if (cosmetic) {
198+ println " COSMETIC (${ cosmetic.size()} ):"
199+ cosmetic. each { Map<String , Object > ch -> println " ${ lib.renderChange(ch)} " }
200+ }
201+ } else {
202+ println " \n No schema changes since ${ sinceRef} ."
203+ }
204+
205+ String bumpKind = lib. recommendBump(changes)
206+ if (bumpKind == ' none' ) {
207+ println " \n Recommended bump: none — no version change needed."
208+ System . exit(0 )
209+ }
210+
211+ String newVersion = lib. applyBump(starterVersion, bumpKind)
212+ println " \n Recommended bump: ${ bumpKind} (${ starterVersion} → ${ newVersion} )"
213+
214+ if (! ciMode) {
215+ println " (dry-run; pass --ci to apply)"
216+ System . exit(1 )
217+ }
218+
219+ // Apply the bump. Note: bumpVersion reads the version from the file,
220+ // applies the kind, and writes the result. Since the on-disk version
221+ // may have drifted from `starterVersion` (e.g., if a prior bump landed
222+ // but no release was cut), we explicitly write `newVersion` rather
223+ // than re-bumping the on-disk value.
224+ if (currentVersion != newVersion) {
225+ String text = CONFIG_DEF_PATH . text
226+ java.util.regex.Pattern versionLine = java.util.regex.Pattern . compile(/ (?m)^(\s *version:\s *)(\S +)(\s *)$/ )
227+ StringBuffer sb = new StringBuffer ()
228+ def matcher = versionLine. matcher(text)
229+ int matches = 0
230+ while (matcher. find()) {
231+ matcher. appendReplacement(sb,
232+ java.util.regex.Matcher . quoteReplacement(
233+ " ${ matcher.group(1)}${ newVersion}${ matcher.group(3)} "
234+ )
235+ )
236+ matches++
237+ }
238+ matcher. appendTail(sb)
239+ if (matches != 1 ) {
240+ throw new IllegalStateException (
241+ " ${ CONFIG_DEF_PATH} : expected exactly 1 'version:' line, found ${ matches} "
242+ )
243+ }
244+ CONFIG_DEF_PATH . text = sb. toString()
245+ println " Wrote: ${ CONFIG_DEF_PATH} (version: ${ currentVersion} → ${ newVersion} )"
246+ } else {
247+ println " On-disk version already matches the bumped value (${ newVersion} ); no write needed."
248+ }
249+
250+ System . exit(1 )
251+ } catch (Throwable t) {
252+ System . err. println " Bump failed: ${ t.class.name} : ${ t.message} "
253+ t. printStackTrace(System . err)
254+ System . exit(2 )
255+ }
256+ }
0 commit comments