Skip to main content

gitversion_rs/buildagent/
mod.rs

1//! Build agent (CI) integrations.
2//!
3//! Ports each agent from the original `GitVersion.BuildAgents`. Detects the current CI
4//! from environment variables and outputs variables / build numbers in the format expected
5//! by that CI. When `update_build_number` is false the build-number line is omitted
6//! (mirrors the original `BuildAgentBase.WriteIntegration` behaviour).
7
8use crate::output::VersionVariables;
9use std::env;
10
11/// Escape a value for TeamCity/MyGet service messages.
12fn escape_value(v: &str) -> String {
13    v.replace('|', "||")
14        .replace('\'', "|'")
15        .replace('[', "|[")
16        .replace(']', "|]")
17        .replace('\r', "|r")
18        .replace('\n', "|n")
19}
20
21/// Common interface for build agents.
22pub trait BuildAgent {
23    /// Agent name matching the original class name (`GetType().Name`).
24    fn name(&self) -> &'static str;
25
26    /// Returns the build-number line (typically FullSemVer). Returns an empty string for CIs that don't support it.
27    fn set_build_number(&self, vars: &VersionVariables) -> String {
28        vars.full_sem_ver.clone()
29    }
30
31    /// Returns the output lines for a single variable.
32    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String>;
33
34    /// Returns the full integration output (including log lines).
35    fn write_integration(&self, vars: &VersionVariables, update_build_number: bool) -> Vec<String> {
36        base_integration(self, vars, update_build_number)
37    }
38}
39
40/// Default WriteIntegration behaviour (mirrors the original `BuildAgentBase`).
41fn base_integration(
42    agent: &(impl BuildAgent + ?Sized),
43    vars: &VersionVariables,
44    update_build_number: bool,
45) -> Vec<String> {
46    let mut out = Vec::new();
47    if update_build_number {
48        out.push(format!("Set Build Number for '{}'.", agent.name()));
49        // Agents whose set_build_number returns an empty string (BuildKite, SpaceAutomation, etc.)
50        // do not emit a build-number line (matches the original behaviour).
51        let bn = agent.set_build_number(vars);
52        if !bn.is_empty() {
53            out.push(bn);
54        }
55    }
56    out.push(format!("Set Output Variables for '{}'.", agent.name()));
57    for (key, value) in vars.to_map() {
58        out.extend(agent.set_output_variable(&key, &value));
59    }
60    out
61}
62
63// ─────────────────────────── Agent implementations ───────────────────────────
64
65/// TeamCity: `##teamcity[...]` service message.
66struct TeamCity;
67impl BuildAgent for TeamCity {
68    fn name(&self) -> &'static str {
69        "TeamCity"
70    }
71    fn set_build_number(&self, vars: &VersionVariables) -> String {
72        format!(
73            "##teamcity[buildNumber '{}']",
74            escape_value(&vars.full_sem_ver)
75        )
76    }
77    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
78        let e = escape_value(value);
79        vec![
80            format!("##teamcity[setParameter name='GitVersion.{name}' value='{e}']"),
81            format!("##teamcity[setParameter name='system.GitVersion.{name}' value='{e}']"),
82        ]
83    }
84}
85
86/// MyGet: `##myget[...]`.
87struct MyGet;
88impl BuildAgent for MyGet {
89    fn name(&self) -> &'static str {
90        "MyGet"
91    }
92    fn set_build_number(&self, vars: &VersionVariables) -> String {
93        format!(
94            "##myget[buildNumber '{}']",
95            escape_value(&vars.full_sem_ver)
96        )
97    }
98    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
99        vec![format!(
100            "##myget[setParameter name='GitVersion.{name}' value='{}']",
101            escape_value(value)
102        )]
103    }
104}
105
106/// Azure Pipelines: `##vso[...]`.
107struct AzurePipelines;
108impl BuildAgent for AzurePipelines {
109    fn name(&self) -> &'static str {
110        "AzurePipelines"
111    }
112    fn set_build_number(&self, vars: &VersionVariables) -> String {
113        // If BUILD_BUILDNUMBER is absent, fall back to FullSemVer (with the "+0" suffix stripped).
114        match env::var("BUILD_BUILDNUMBER") {
115            Ok(bn) if !bn.trim().is_empty() => {
116                let replaced = replace_azure_vars(&bn, vars);
117                if replaced != bn {
118                    format!("##vso[build.updatebuildnumber]{replaced}")
119                } else {
120                    let v = vars
121                        .full_sem_ver
122                        .strip_suffix("+0")
123                        .unwrap_or(&vars.full_sem_ver);
124                    format!("##vso[build.updatebuildnumber]{v}")
125                }
126            }
127            _ => vars.full_sem_ver.clone(),
128        }
129    }
130    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
131        vec![
132            format!("##vso[task.setvariable variable=GitVersion.{name}]{value}"),
133            format!("##vso[task.setvariable variable=GitVersion.{name};isOutput=true]{value}"),
134        ]
135    }
136}
137
138fn replace_azure_vars(build_number: &str, vars: &VersionVariables) -> String {
139    let mut out = build_number.to_string();
140    for (key, value) in vars.to_map() {
141        out = out.replace(&format!("$(GITVERSION_{key})"), &value);
142        out = out.replace(&format!("$(GITVERSION.{key})"), &value);
143    }
144    out
145}
146
147/// ContinuaCI: `@@continua[...]`.
148struct ContinuaCi;
149impl BuildAgent for ContinuaCi {
150    fn name(&self) -> &'static str {
151        "ContinuaCi"
152    }
153    fn set_build_number(&self, vars: &VersionVariables) -> String {
154        format!("@@continua[setBuildVersion value='{}']", vars.full_sem_ver)
155    }
156    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
157        vec![format!(
158            "@@continua[setVariable name='GitVersion_{name}' value='{value}' skipIfNotDefined='true']"
159        )]
160    }
161}
162
163/// EnvRun: `@@envrun[...]`.
164struct EnvRun;
165impl BuildAgent for EnvRun {
166    fn name(&self) -> &'static str {
167        "EnvRun"
168    }
169    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
170        vec![format!(
171            "@@envrun[set name='GitVersion_{name}' value='{value}']"
172        )]
173    }
174}
175
176/// Shared `GitVersion_{name}={value}` format used by TravisCI, Drone, GitLabCi, Jenkins, and CodeBuild.
177fn key_value_line(name: &str, value: &str) -> Vec<String> {
178    vec![format!("GitVersion_{name}={value}")]
179}
180
181struct TravisCi;
182impl BuildAgent for TravisCi {
183    fn name(&self) -> &'static str {
184        "TravisCi"
185    }
186    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
187        key_value_line(name, value)
188    }
189}
190
191struct Drone;
192impl BuildAgent for Drone {
193    fn name(&self) -> &'static str {
194        "Drone"
195    }
196    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
197        key_value_line(name, value)
198    }
199}
200
201/// Write variables to a `gitversion.properties` file (shared by GitLabCi, Jenkins, and CodeBuild).
202fn write_properties_file(vars: &VersionVariables) {
203    let lines: Vec<String> = vars
204        .to_map()
205        .iter()
206        .map(|(k, v)| format!("GitVersion_{k}={v}"))
207        .collect();
208    let _ = std::fs::write("gitversion.properties", lines.join("\n") + "\n");
209}
210
211struct GitLabCi;
212impl BuildAgent for GitLabCi {
213    fn name(&self) -> &'static str {
214        "GitLabCi"
215    }
216    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
217        key_value_line(name, value)
218    }
219    fn write_integration(&self, vars: &VersionVariables, ubn: bool) -> Vec<String> {
220        let mut out = base_integration(self, vars, ubn);
221        out.push("Outputting variables to 'gitversion.properties' ... ".into());
222        write_properties_file(vars);
223        out
224    }
225}
226
227struct Jenkins;
228impl BuildAgent for Jenkins {
229    fn name(&self) -> &'static str {
230        "Jenkins"
231    }
232    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
233        key_value_line(name, value)
234    }
235    fn write_integration(&self, vars: &VersionVariables, ubn: bool) -> Vec<String> {
236        let mut out = base_integration(self, vars, ubn);
237        write_properties_file(vars);
238        out.push("Outputting variables to 'gitversion.properties' ... ".into());
239        out
240    }
241}
242
243struct CodeBuild;
244impl BuildAgent for CodeBuild {
245    fn name(&self) -> &'static str {
246        "CodeBuild"
247    }
248    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
249        key_value_line(name, value)
250    }
251    fn write_integration(&self, vars: &VersionVariables, ubn: bool) -> Vec<String> {
252        let mut out = base_integration(self, vars, ubn);
253        write_properties_file(vars);
254        out.push("Outputting variables to 'gitversion.properties' ... ".into());
255        out
256    }
257}
258
259/// BitBucket Pipelines: uppercase keys. Writes a properties (Bash) and ps1 (PowerShell) file, plus guidance lines.
260struct BitBucketPipelines;
261impl BuildAgent for BitBucketPipelines {
262    fn name(&self) -> &'static str {
263        "BitBucketPipelines"
264    }
265    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
266        vec![format!("GITVERSION_{}={value}", name.to_uppercase())]
267    }
268    fn write_integration(&self, vars: &VersionVariables, ubn: bool) -> Vec<String> {
269        let mut out = base_integration(self, vars, ubn);
270        let pf = "gitversion.properties";
271        let ps1 = "gitversion.ps1";
272        let exports: Vec<String> = vars
273            .to_map()
274            .iter()
275            .map(|(k, v)| format!("export GITVERSION_{}={v}", k.to_uppercase()))
276            .collect();
277        let _ = std::fs::write(pf, exports.join("\n") + "\n");
278        // Guidance lines from the original BitBucketPipelines.WriteIntegration (Bash/PowerShell).
279        out.push(format!("Outputting variables to '{pf}' for Bash,"));
280        out.push(format!("and to '{ps1}' for Powershell ... "));
281        out.push(
282            "To import the file into your build environment, add the following line to your build step:"
283                .into(),
284        );
285        out.push("Bash:".into());
286        out.push(format!("  - source {pf}"));
287        out.push("Powershell:".into());
288        out.push(format!("  - . .\\{ps1}"));
289        out.push(String::new());
290        out.push("To reuse the file across build steps, add the file as a build artifact:".into());
291        out.push("Bash:".into());
292        out.push("  artifacts:".into());
293        out.push(format!("    - {pf}"));
294        out.push("Powershell:".into());
295        out.push("  artifacts:".into());
296        out.push(format!("    - {ps1}"));
297        out
298    }
299}
300
301/// GitHub Actions: writes variables to the `$GITHUB_ENV` file; stdout carries log lines only.
302struct GitHubActions;
303impl BuildAgent for GitHubActions {
304    fn name(&self) -> &'static str {
305        "GitHubActions"
306    }
307    fn set_build_number(&self, _vars: &VersionVariables) -> String {
308        String::new()
309    }
310    fn set_output_variable(&self, _name: &str, _value: &str) -> Vec<String> {
311        Vec::new()
312    }
313    fn write_integration(&self, vars: &VersionVariables, ubn: bool) -> Vec<String> {
314        let mut out = base_integration(self, vars, ubn);
315        match env::var("GITHUB_ENV") {
316            Ok(path) => {
317                out.push(format!("Writing version variables to $GITHUB_ENV file for '{}'.", self.name()));
318                let lines: Vec<String> = vars
319                    .to_map()
320                    .iter()
321                    .filter(|(_, v)| !v.is_empty())
322                    .map(|(k, v)| format!("GitVersion_{k}={v}"))
323                    .collect();
324                use std::io::Write;
325                if let Ok(mut f) =
326                    std::fs::OpenOptions::new().create(true).append(true).open(&path)
327                {
328                    let _ = writeln!(f, "{}", lines.join("\n"));
329                }
330            }
331            Err(_) => out.push(
332                "Unable to write GitVersion variables to $GITHUB_ENV because the environment variable is not set."
333                    .into(),
334            ),
335        }
336        out
337    }
338}
339
340/// CIs without an output mechanism (BuildKite, SpaceAutomation): emit log lines only.
341struct BuildKite;
342impl BuildAgent for BuildKite {
343    fn name(&self) -> &'static str {
344        "BuildKite"
345    }
346    fn set_build_number(&self, _vars: &VersionVariables) -> String {
347        String::new()
348    }
349    fn set_output_variable(&self, _name: &str, _value: &str) -> Vec<String> {
350        Vec::new()
351    }
352}
353
354struct SpaceAutomation;
355impl BuildAgent for SpaceAutomation {
356    fn name(&self) -> &'static str {
357        "SpaceAutomation"
358    }
359    fn set_build_number(&self, _vars: &VersionVariables) -> String {
360        String::new()
361    }
362    fn set_output_variable(&self, _name: &str, _value: &str) -> Vec<String> {
363        Vec::new()
364    }
365}
366
367/// AppVeyor: uses REST API calls in practice. Offline, only log lines are emitted.
368struct AppVeyor;
369impl BuildAgent for AppVeyor {
370    fn name(&self) -> &'static str {
371        "AppVeyor"
372    }
373    fn set_build_number(&self, vars: &VersionVariables) -> String {
374        format!("Set AppVeyor build number to '{}'.", vars.full_sem_ver)
375    }
376    fn set_output_variable(&self, name: &str, value: &str) -> Vec<String> {
377        vec![format!(
378            "Adding Environment Variable. name='GitVersion_{name}' value='{value}']"
379        )]
380    }
381}
382
383impl AppVeyor {
384    /// The original AppVeyor integration uses REST API calls (PUT api/build,
385    /// POST api/build/variables) rather than stdout commands, so it cannot be compared
386    /// against a build-server stdout golden file. Instead, the request body (JSON) format
387    /// is replicated here and verified by unit tests (actual transmission is environment-dependent).
388    #[cfg(test)]
389    fn build_number_body(vars: &VersionVariables, build_number: &str) -> String {
390        format!(
391            r#"{{"version":"{}.build.{}"}}"#,
392            vars.full_sem_ver, build_number
393        )
394    }
395
396    #[cfg(test)]
397    fn output_variable_body(name: &str, value: &str) -> String {
398        format!(r#"{{"name":"GitVersion_{name}","value":"{value}"}}"#)
399    }
400}
401
402/// Instantiate an agent by name (matching the original `GetType().Name`). Used for tests and explicit selection.
403pub fn by_name(name: &str) -> Option<Box<dyn BuildAgent>> {
404    let agent: Box<dyn BuildAgent> = match name {
405        "TeamCity" => Box::new(TeamCity),
406        "MyGet" => Box::new(MyGet),
407        "AzurePipelines" => Box::new(AzurePipelines),
408        "ContinuaCi" => Box::new(ContinuaCi),
409        "EnvRun" => Box::new(EnvRun),
410        "TravisCI" | "TravisCi" => Box::new(TravisCi),
411        "Drone" => Box::new(Drone),
412        "GitLabCi" => Box::new(GitLabCi),
413        "Jenkins" => Box::new(Jenkins),
414        "CodeBuild" => Box::new(CodeBuild),
415        "BitBucketPipelines" => Box::new(BitBucketPipelines),
416        "GitHubActions" => Box::new(GitHubActions),
417        "BuildKite" => Box::new(BuildKite),
418        "SpaceAutomation" => Box::new(SpaceAutomation),
419        "AppVeyor" => Box::new(AppVeyor),
420        _ => return None,
421    };
422    Some(agent)
423}
424
425/// Detect the current build agent from environment variables. Order follows the original registration order.
426pub fn detect() -> Option<Box<dyn BuildAgent>> {
427    let has = |k: &str| env::var(k).map(|v| !v.is_empty()).unwrap_or(false);
428
429    if has("TEAMCITY_VERSION") {
430        Some(Box::new(TeamCity))
431    } else if has("TF_BUILD") {
432        Some(Box::new(AzurePipelines))
433    } else if has("GITHUB_ACTIONS") {
434        Some(Box::new(GitHubActions))
435    } else if has("GITLAB_CI") {
436        Some(Box::new(GitLabCi))
437    } else if has("JENKINS_URL") {
438        Some(Box::new(Jenkins))
439    } else if has("CODEBUILD_WEBHOOK_HEAD_REF") {
440        Some(Box::new(CodeBuild))
441    } else if has("TRAVIS") {
442        Some(Box::new(TravisCi))
443    } else if has("DRONE") {
444        Some(Box::new(Drone))
445    } else if has("APPVEYOR") {
446        Some(Box::new(AppVeyor))
447    } else if has("ENVRUN_DATABASE") {
448        Some(Box::new(EnvRun))
449    } else if has("ContinuaCI.Version") {
450        Some(Box::new(ContinuaCi))
451    } else if has("BITBUCKET_WORKSPACE") {
452        Some(Box::new(BitBucketPipelines))
453    } else if has("BUILDKITE") {
454        Some(Box::new(BuildKite))
455    } else if has("JB_SPACE_PROJECT_KEY") {
456        Some(Box::new(SpaceAutomation))
457    } else if env::var("BuildRunner")
458        .map(|v| v.eq_ignore_ascii_case("MyGet"))
459        .unwrap_or(false)
460    {
461        Some(Box::new(MyGet))
462    } else {
463        None
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    fn sample() -> VersionVariables {
472        VersionVariables {
473            full_sem_ver: "1.0.1-1".into(),
474            ..Default::default()
475        }
476    }
477
478    #[test]
479    fn appveyor_http_body_matches_dotnet() {
480        // Request body format for the original AppVeyor PUT api/build / POST api/build/variables.
481        let vars = VersionVariables {
482            full_sem_ver: "1.2.3-beta.1".into(),
483            ..Default::default()
484        };
485        assert_eq!(
486            AppVeyor::build_number_body(&vars, "42"),
487            r#"{"version":"1.2.3-beta.1.build.42"}"#
488        );
489        assert_eq!(
490            AppVeyor::output_variable_body("Major", "1"),
491            r#"{"name":"GitVersion_Major","value":"1"}"#
492        );
493    }
494
495    #[test]
496    fn teamcity_format() {
497        let a = TeamCity;
498        assert_eq!(
499            a.set_build_number(&sample()),
500            "##teamcity[buildNumber '1.0.1-1']"
501        );
502        assert_eq!(
503            a.set_output_variable("FullSemVer", "1.0.1-1"),
504            vec![
505                "##teamcity[setParameter name='GitVersion.FullSemVer' value='1.0.1-1']",
506                "##teamcity[setParameter name='system.GitVersion.FullSemVer' value='1.0.1-1']",
507            ]
508        );
509    }
510
511    #[test]
512    fn teamcity_escapes_special_chars() {
513        let a = TeamCity;
514        assert_eq!(
515            a.set_output_variable("X", "a'b[c]"),
516            vec![
517                "##teamcity[setParameter name='GitVersion.X' value='a|'b|[c|]']",
518                "##teamcity[setParameter name='system.GitVersion.X' value='a|'b|[c|]']",
519            ]
520        );
521    }
522
523    #[test]
524    fn azure_format() {
525        let a = AzurePipelines;
526        assert_eq!(
527            a.set_output_variable("Major", "1"),
528            vec![
529                "##vso[task.setvariable variable=GitVersion.Major]1",
530                "##vso[task.setvariable variable=GitVersion.Major;isOutput=true]1",
531            ]
532        );
533    }
534
535    #[test]
536    fn key_value_agents() {
537        assert_eq!(
538            GitLabCi.set_output_variable("Sha", "abc"),
539            vec!["GitVersion_Sha=abc"]
540        );
541        assert_eq!(
542            TravisCi.set_output_variable("Sha", "abc"),
543            vec!["GitVersion_Sha=abc"]
544        );
545        assert_eq!(
546            BitBucketPipelines.set_output_variable("FullSemVer", "1.0.1-1"),
547            vec!["GITVERSION_FULLSEMVER=1.0.1-1"]
548        );
549    }
550
551    #[test]
552    fn integration_skips_build_number_when_disabled() {
553        let out = TeamCity.write_integration(&sample(), false);
554        assert!(out.iter().all(|l| !l.contains("buildNumber")));
555        assert!(out
556            .iter()
557            .any(|l| l.starts_with("Set Output Variables for 'TeamCity'.")));
558    }
559
560    #[test]
561    fn integration_includes_build_number_when_enabled() {
562        let out = TeamCity.write_integration(&sample(), true);
563        assert!(out.iter().any(|l| l == "##teamcity[buildNumber '1.0.1-1']"));
564    }
565}