Skip to content

Commit 1c7c407

Browse files
fix(compile): enforce ADO build-number rules for pipeline_agent_name (#576)
* fix(compile): sanitize build-number pipeline agent name marker Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/3a09eb36-5df5-4c49-a3c8-da542bb240d1 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fix(tests): update lock pipeline names for sanitized build-number marker Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/3a09eb36-5df5-4c49-a3c8-da542bb240d1 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent aab58b6 commit 1c7c407

28 files changed

Lines changed: 121 additions & 49 deletions

docs/template-markers.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ have not been validated against YAML scalar rules.
105105
> ⚠️ This marker is only safe inside a position that is **not parsed as
106106
> YAML** (currently only `src/data/threat-analysis.md`, which is a
107107
> markdown body). YAML positions inside the generated pipelines use
108-
> [`{{ pipeline_name }}`](#-pipeline_name-) (top-level `name:` line)
108+
> [`{{ pipeline_agent_name }}`](#-pipeline_agent_name-) (top-level `name:` line)
109109
> or [`{{ agent_display_name }}`](#-agent_display_name-)
110110
> (`displayName:` positions). Both emit a fully-quoted-and-escaped
111111
> double-quoted YAML scalar, so colons, embedded `"`, and other
@@ -129,24 +129,27 @@ For an agent named `My "special": agent`, this expands to:
129129
Used in `src/data/1es-base.yml` (1ES stage display name) and
130130
`src/data/stage-base.yml` (stage-target stage display name). The marker
131131
deliberately does **not** include the `-$(BuildID)` suffix that
132-
[`{{ pipeline_name }}`](#-pipeline_name-) carries — stage labels are
132+
[`{{ pipeline_agent_name }}`](#-pipeline_agent_name-) carries — stage labels are
133133
static and don't need per-run uniqueness.
134134

135-
## {{ pipeline_name }}
135+
## {{ pipeline_agent_name }}
136136

137-
Should be replaced with the front-matter agent name plus the
138-
`-$(BuildID)` suffix, always emitted as a **YAML double-quoted scalar**
139-
with the same escaping rules as `{{ agent_display_name }}`. Used only
140-
for the top-level pipeline `name:` line, which in Azure DevOps is the
141-
build-number format string. The `-$(BuildID)` suffix is the
137+
Should be replaced with a sanitized front-matter agent name plus the
138+
`-$(BuildID)` suffix, emitted as a **YAML double-quoted scalar**. Used
139+
only for the top-level pipeline `name:` line, which in Azure DevOps is
140+
the build-number format string. The marker strips build-number-invalid
141+
characters (`"`, `/`, `:`, `<`, `>`, `\`, `|`, `?`, `@`, `*`), trims
142+
trailing `.` from the name fragment, and enforces the 255-character
143+
build-number limit when combined with the `-$(BuildID)` suffix. The
144+
suffix is the
142145
[varying token ADO requires](https://learn.microsoft.com/azure/devops/pipelines/process/run-number)
143146
to give each run a unique display name in the runs view; without it,
144147
every run shows the same name.
145148

146149
For an agent named `Daily safe-output smoke: noop`, this expands to:
147150

148151
```yaml
149-
name: "Daily safe-output smoke: noop-$(BuildID)"
152+
name: "Daily safe-output smoke noop-$(BuildID)"
150153
```
151154

152155
`$(BuildID)` is an ADO macro and is expanded at queue time after YAML

src/compile/common.rs

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,40 @@ pub fn sanitize_filename(name: &str) -> String {
996996
.join("-")
997997
}
998998

999+
const ADO_BUILD_NUMBER_MAX_LEN: usize = 255;
1000+
const ADO_BUILD_ID_SUFFIX: &str = "-$(BuildID)";
1001+
1002+
/// Sanitize front-matter agent name for ADO build-number format strings.
1003+
///
1004+
/// Rules enforced:
1005+
/// - Remove characters disallowed by Azure DevOps build numbers:
1006+
/// `"`, `/`, `:`, `<`, `>`, `\`, `|`, `?`, `@`, `*`
1007+
/// - Trim leading/trailing whitespace
1008+
/// - Ensure the resulting build number format (`<name>-$(BuildID)`) fits in 255 chars
1009+
/// - Ensure the name fragment does not end with `.`
1010+
fn sanitize_pipeline_agent_name(name: &str) -> String {
1011+
let mut sanitized = String::with_capacity(name.len());
1012+
for ch in name.trim().chars() {
1013+
if matches!(ch, '"' | '/' | ':' | '<' | '>' | '\\' | '|' | '?' | '@' | '*') {
1014+
continue;
1015+
}
1016+
sanitized.push(ch);
1017+
}
1018+
1019+
let mut sanitized = sanitized.trim().trim_end_matches('.').to_string();
1020+
let max_agent_len = ADO_BUILD_NUMBER_MAX_LEN.saturating_sub(ADO_BUILD_ID_SUFFIX.len());
1021+
if sanitized.chars().count() > max_agent_len {
1022+
sanitized = sanitized.chars().take(max_agent_len).collect();
1023+
sanitized = sanitized.trim_end_matches('.').to_string();
1024+
}
1025+
1026+
if sanitized.is_empty() {
1027+
"pipeline".to_string()
1028+
} else {
1029+
sanitized
1030+
}
1031+
}
1032+
9991033
/// Emit `s` as a YAML double-quoted scalar (always quoted, never plain).
10001034
///
10011035
/// We always quote because the value is substituted into YAML positions
@@ -2894,12 +2928,15 @@ pub async fn compile_shared(
28942928
let checkout_self = generate_checkout_self();
28952929
let agent_name = sanitize_filename(&front_matter.name);
28962930
// Top-level pipeline `name:` value (the ADO build-number format).
2897-
// Always quoted so colons / embedded `"` in the agent name can't
2898-
// break parsing. Includes `-$(BuildID)` because ADO needs a varying
2899-
// token in the build-number format — without one, every run shows
2900-
// the same name in the runs view.
2901-
let pipeline_name =
2902-
yaml_double_quoted(&format!("{}-$(BuildID)", front_matter.name));
2931+
// We sanitize invalid build-number characters from the agent name and
2932+
// always quote the final scalar for YAML safety. Includes `-$(BuildID)`
2933+
// because ADO needs a varying token in the build-number format —
2934+
// without one, every run shows the same name in the runs view.
2935+
let pipeline_name = yaml_double_quoted(&format!(
2936+
"{}{}",
2937+
sanitize_pipeline_agent_name(&front_matter.name),
2938+
ADO_BUILD_ID_SUFFIX
2939+
));
29032940
// Stage / job `displayName:` value. Always quoted (same escaping
29042941
// rationale as `pipeline_name`) but with NO BuildID suffix — stage
29052942
// labels are static and shouldn't carry per-run uniqueness suffixes.
@@ -3118,6 +3155,9 @@ pub async fn compile_shared(
31183155
("{{ agent }}", &agent_name),
31193156
("{{ agent_name }}", &front_matter.name),
31203157
("{{ agent_display_name }}", &agent_display_name),
3158+
("{{ pipeline_agent_name }}", &pipeline_name),
3159+
// Backward-compatible alias for templates that still reference the
3160+
// older marker name.
31213161
("{{ pipeline_name }}", &pipeline_name),
31223162
("{{ agent_description }}", &front_matter.description),
31233163
("{{ engine_run }}", &engine_run),
@@ -4046,6 +4086,36 @@ mod tests {
40464086
assert_eq!(sanitize_filename("test_case"), "test-case");
40474087
}
40484088

4089+
// ─── sanitize_pipeline_agent_name ───────────────────────────────────────
4090+
4091+
#[test]
4092+
fn test_sanitize_pipeline_agent_name_removes_invalid_build_number_chars() {
4093+
assert_eq!(
4094+
sanitize_pipeline_agent_name(r#"Daily safe-output smoke: "noop" @nightly"#),
4095+
"Daily safe-output smoke noop nightly"
4096+
);
4097+
}
4098+
4099+
#[test]
4100+
fn test_sanitize_pipeline_agent_name_trims_trailing_dot() {
4101+
assert_eq!(sanitize_pipeline_agent_name("Agent name."), "Agent name");
4102+
}
4103+
4104+
#[test]
4105+
fn test_sanitize_pipeline_agent_name_enforces_length_budget() {
4106+
let input = "x".repeat(ADO_BUILD_NUMBER_MAX_LEN);
4107+
let sanitized = sanitize_pipeline_agent_name(&input);
4108+
assert_eq!(
4109+
sanitized.chars().count(),
4110+
ADO_BUILD_NUMBER_MAX_LEN - ADO_BUILD_ID_SUFFIX.len()
4111+
);
4112+
}
4113+
4114+
#[test]
4115+
fn test_sanitize_pipeline_agent_name_fallback_when_empty_after_sanitize() {
4116+
assert_eq!(sanitize_pipeline_agent_name(":@?*"), "pipeline");
4117+
}
4118+
40494119
// ─── yaml_double_quoted ──────────────────────────────────────────────────
40504120

40514121
#[test]

src/data/1es-base.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# This template extends the 1ES Unofficial Pipeline Template with Copilot CLI,
33
# AWF network isolation, and MCP Gateway — matching the standalone pipeline model.
44

5-
name: {{ pipeline_name }}
5+
name: {{ pipeline_agent_name }}
66
{{ parameters }}
77
{{ schedule }}
88
{{ pr_trigger }}

src/data/base.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
name: {{ pipeline_name }}
2+
name: {{ pipeline_agent_name }}
33
{{ parameters }}
44
resources:
55
repositories:

tests/compiler_tests.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ fn assert_required_markers(content: &str) {
6969
"{{ checkout_repositories }}",
7070
"{{ allowed_domains }}",
7171
"{{ source_path }}",
72-
"{{ pipeline_name }}",
72+
"{{ pipeline_agent_name }}",
7373
"{{ engine_run }}",
7474
"{{ compiler_version }}",
7575
"{{ integrity_check }}",
@@ -3531,22 +3531,21 @@ fn test_1es_compiled_output_is_valid_yaml() {
35313531
/// Names with embedded `"` and `:` must survive YAML escaping in both
35323532
/// the top-level `name:` line and any `displayName:` positions.
35333533
///
3534-
/// Regression: until `{{ pipeline_name }}` was introduced both positions
3534+
/// Regression: until `{{ pipeline_agent_name }}` was introduced both positions
35353535
/// used a bare `{{ agent_name }}` substitution which broke if the
35363536
/// front-matter name contained colons (`name: a: b` parsed as a YAML
35373537
/// mapping) or embedded double quotes (`displayName: "a "b" c"` parsed
35383538
/// as broken scalars). Now both positions go through `yaml_double_quoted`
3539-
/// via a single `{{ pipeline_name }}` marker.
3539+
/// via a dedicated pipeline name marker.
35403540
#[test]
35413541
fn test_compiled_yaml_survives_tricky_agent_name_standalone() {
35423542
let compiled = compile_fixture("tricky-name-agent.md");
35433543
assert_valid_yaml(&compiled, "tricky-name-agent.md");
35443544

3545-
// The top-level pipeline name must contain the escaped form of the
3546-
// embedded `"` (rendered as `\"`) AND retain the colon.
3545+
// Build-number names must strip invalid characters such as `"` and `:`.
35473546
assert!(
3548-
compiled.contains(r#"name: "My \"special\": agent with quotes-$(BuildID)""#),
3549-
"standalone output should contain escaped pipeline name; got:\n{compiled}"
3547+
compiled.contains(r#"name: "My special agent with quotes-$(BuildID)""#),
3548+
"standalone output should contain sanitized pipeline name; got:\n{compiled}"
35503549
);
35513550
}
35523551

@@ -3559,8 +3558,8 @@ fn test_compiled_yaml_survives_tricky_agent_name_1es() {
35593558
// the ADO build-number format needs a varying token; the stage
35603559
// displayName does NOT carry the suffix (stage labels are static).
35613560
assert!(
3562-
compiled.contains(r#"name: "My \"special\": agent with quotes (1ES)-$(BuildID)""#),
3563-
"1ES output should contain escaped pipeline name; got:\n{compiled}"
3561+
compiled.contains(r#"name: "My special agent with quotes (1ES)-$(BuildID)""#),
3562+
"1ES output should contain sanitized pipeline name; got:\n{compiled}"
35643563
);
35653564
assert!(
35663565
compiled.contains(r#"displayName: "My \"special\": agent with quotes (1ES)""#),

tests/safe-outputs/add-build-tag.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/safe-outputs/add-pr-comment.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/safe-outputs/comment-on-work-item.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/safe-outputs/create-branch.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/safe-outputs/create-git-tag.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)