Skip to content

Commit 1d5221a

Browse files
Merge origin/main and resolve doc conflicts
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
2 parents e4297a5 + d589900 commit 1d5221a

13 files changed

Lines changed: 145 additions & 124 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,9 @@ the service connections. Approve the permissions and the pipeline is ready.
251251
| `post-steps` | list | — | Inline steps after agent runs |
252252
| `setup` | list | — | Separate job before agentic task |
253253
| `teardown` | list | — | Separate job after safe outputs |
254-
| `network` | object | — | Additional allowed/blocked hosts |
255-
| `inlined-imports` | boolean | `false` | Resolve `{{#runtime-import …}}` markers at compile time instead of at pipeline runtime. When `false` (default), prompt-body edits do not require recompilation. |
256-
| `env` | map | — | Workflow-level environment variables (reserved, not yet implemented) |
254+
| `network` | object | — | Additional allowed/blocked hosts |
255+
| `inlined-imports` | boolean | `false` | When `true`, resolves all `{{#runtime-import …}}` markers at compile time; the generated YAML is self-contained but prompt-body edits require recompilation. See [runtime-imports.md](docs/runtime-imports.md). |
256+
| `env` | map | — | Workflow-level environment variables (reserved, not yet implemented) |
257257

258258
### Markdown Body
259259

prompts/create-ado-agentic-workflow.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -563,18 +563,15 @@ Omit `parameters:` if no runtime configuration knobs are needed.
563563

564564
### Step 16 — Inlined Imports (advanced, optional)
565565

566-
Controls when `{{#runtime-import ...}}` markers in the markdown body are resolved. Defaults to `false` — leave it unset for most workflows.
566+
By default (`inlined-imports: false`), any `{{#runtime-import …}}` markers in the agent body — including the implicit marker that reloads the body itself — are resolved at **pipeline runtime**. This means editing the `.md` agent body does not require recompiling the `.lock.yml` pipeline.
567+
568+
Set `inlined-imports: true` only when you need a fully self-contained pipeline YAML (e.g., for auditing or air-gapped deployment):
567569

568570
```yaml
569-
inlined-imports: true # Resolve all runtime-import markers at compile time
571+
inlined-imports: true
570572
```
571573

572-
| Value | Behavior |
573-
|-------|----------|
574-
| `false` (default) | Markers resolved at pipeline runtime — prompt-body edits do **not** require recompiling |
575-
| `true` | Markers resolved at compile time — the generated `.lock.yml` is fully self-contained, but prompt-body edits require `ado-aw compile` |
576-
577-
Only set `inlined-imports: true` if you need the pipeline file to be completely standalone (e.g., for environments where the source `.md` file is not accessible at pipeline runtime). See `docs/runtime-imports.md` for full details.
574+
**Trade-off**: with `inlined-imports: true`, every change to the agent instructions requires running `ado-aw compile` and committing the updated `.lock.yml`. Omit this field (or set it to `false`) for the typical edit-without-recompile workflow.
578575

579576
---
580577

site/src/content/docs/setup/cli.mdx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ Options:
8080
- `--org <url>` -- Azure DevOps organization URL or bare org name
8181
- `--project <name>` -- Azure DevOps project name
8282
- `--pat <pat>` -- PAT for ADO API authentication
83-
- `--definition-ids <ids>` -- explicit comma-separated definition IDs (skips auto-detection)
83+
- `--definition-ids <ids>` -- explicit comma-separated definition IDs (skips auto-detection); mutually exclusive with `--all-repos` / `--source`
84+
- `--all-repos` -- **project-scope mode**: search every ado-aw definition in the ADO project, not just those with a local lock file; mutually exclusive with `--definition-ids`
85+
- `--source <path>` -- filter to definitions whose `# ado-aw-metadata` marker references this template path (e.g. `agents/security-scan.md`); activates the discovery code path; pairs with `--all-repos` to scope across the whole project
8486
- `--dry-run` -- print the planned set without calling the ADO API
8587

8688
### `secrets list [path]`
@@ -91,6 +93,7 @@ Options:
9193

9294
- `--json` -- emit machine-readable JSON
9395
- `--org`, `--project`, `--pat`, `--definition-ids` -- same as `secrets set`
96+
- `--all-repos`, `--source` -- same as `secrets set`
9497

9598
### `secrets delete <name> [path]`
9699

@@ -99,8 +102,29 @@ Delete a named variable from every matched definition. No-op when the variable i
99102
Options:
100103

101104
- `--org`, `--project`, `--pat`, `--definition-ids` -- same as `secrets set`
105+
- `--all-repos`, `--source` -- same as `secrets set`
102106
- `--dry-run` -- print the planned deletion without calling the ADO API
103107

108+
### Project-scope discovery (`--all-repos` / `--source`)
109+
110+
By default, `secrets` commands match ADO definitions by scanning local lock files. Two opt-in flags activate **Preview-driven discovery** instead — useful when local checkouts of every consumer pipeline aren't available:
111+
112+
- **`--all-repos`** — search every ado-aw definition in the ADO project, including consumer pipelines that include ado-aw templates but live in other repos. No local checkout of those repos is required.
113+
- **`--source <path>`** — restrict results to definitions whose `# ado-aw-metadata` marker references the given template path. Useful for fan-out token rotation: `ado-aw secrets set GITHUB_TOKEN --source agents/security-scan.md` updates every pipeline that includes that template across the entire project.
114+
115+
Both flags are mutually exclusive with `--definition-ids`. `enable`, `disable`, and `remove` are **not** affected — they retain their source-scoped safety semantics.
116+
117+
```bash
118+
# Rotate GITHUB_TOKEN on every ado-aw pipeline in the project
119+
ado-aw secrets set GITHUB_TOKEN --all-repos
120+
121+
# Update only pipelines that include a specific template
122+
ado-aw secrets set GITHUB_TOKEN --all-repos --source agents/security-scan.md
123+
124+
# Preview which definitions would be updated
125+
ado-aw secrets set GITHUB_TOKEN --all-repos --dry-run
126+
```
127+
104128
### `enable [path]`
105129

106130
Register an ADO build definition for each compiled pipeline discovered under `path` and ensure it is `enabled`. Matches existing definitions by YAML filename first, then by display name; creates a new definition when no match is found.
@@ -240,9 +264,12 @@ ado-aw compile
240264
# Verify a generated pipeline
241265
ado-aw check agent.lock.yml
242266

243-
# Set GITHUB_TOKEN on all matched pipelines
267+
# Set GITHUB_TOKEN on all matched pipelines (local lock files)
244268
ado-aw secrets set GITHUB_TOKEN
245269

270+
# Set GITHUB_TOKEN on every ado-aw pipeline in the project (no local checkout needed)
271+
ado-aw secrets set GITHUB_TOKEN --all-repos
272+
246273
# Register pipelines with ADO and set their token in one step
247274
ado-aw enable --also-set-token
248275

src/fuzzy_schedule.rs

Lines changed: 37 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -924,9 +924,8 @@ mod tests {
924924
let cron2 = generate_cron(&schedule, "test/workflow");
925925
assert_eq!(cron1, cron2, "Same workflow should produce same cron");
926926

927-
let _cron3 = generate_cron(&schedule, "other/workflow");
928-
// Different workflows should (usually) produce different crons
929-
// Note: There's a small chance of collision, but it's unlikely
927+
let cron3 = generate_cron(&schedule, "other/workflow");
928+
assert_ne!(cron1, cron3, "Different workflow IDs should produce different crons");
930929
}
931930

932931
#[test]
@@ -984,22 +983,6 @@ mod tests {
984983
assert_eq!(parts[4], "1", "Day of week should be Monday (1)");
985984
}
986985

987-
#[test]
988-
fn test_between_equal_times_midnight() {
989-
// Test edge case: between midnight and midnight
990-
let schedule = parse_fuzzy_schedule("daily between midnight and midnight").unwrap();
991-
let cron = generate_cron(&schedule, "test/agent");
992-
993-
let parts: Vec<&str> = cron.split_whitespace().collect();
994-
assert_eq!(parts.len(), 5, "Cron should have 5 fields");
995-
996-
let minute: u32 = parts[0].parse().expect("Minute should be a number");
997-
assert!(minute < 60, "Minute should be 0-59");
998-
999-
let hour: u32 = parts[1].parse().expect("Hour should be a number");
1000-
assert!(hour < 24, "Hour should be 0-23");
1001-
}
1002-
1003986
#[test]
1004987
fn test_generate_schedule_yaml() {
1005988
let yaml = generate_schedule_yaml("daily", "test/agent", &[]).unwrap();
@@ -1039,23 +1022,19 @@ mod tests {
10391022
// ─── invalid hour interval error path ────────────────────────────────────
10401023

10411024
#[test]
1042-
fn test_parse_invalid_hour_interval_5h() {
1043-
let err = parse_fuzzy_schedule("every 5h").unwrap_err();
1044-
assert!(
1045-
err.to_string().contains("Valid intervals"),
1046-
"Error for 5h should mention valid intervals: {}",
1047-
err
1048-
);
1049-
}
1050-
1051-
#[test]
1052-
fn test_parse_invalid_hour_interval_7h() {
1053-
let err = parse_fuzzy_schedule("every 7h").unwrap_err();
1054-
assert!(
1055-
err.to_string().contains("not recommended"),
1056-
"Error for 7h should say 'not recommended': {}",
1057-
err
1058-
);
1025+
fn test_parse_invalid_hour_interval() {
1026+
for input in &["every 5h", "every 7h"] {
1027+
let err = parse_fuzzy_schedule(input).unwrap_err();
1028+
let msg = err.to_string();
1029+
assert!(
1030+
msg.contains("not recommended"),
1031+
"Error for {input} should say 'not recommended': {msg}"
1032+
);
1033+
assert!(
1034+
msg.contains("Valid intervals"),
1035+
"Error for {input} should list valid intervals: {msg}"
1036+
);
1037+
}
10591038
}
10601039

10611040
#[test]
@@ -1069,12 +1048,29 @@ mod tests {
10691048
}
10701049

10711050
#[test]
1072-
fn test_backward_compatibility() {
1073-
// Test that simple "hourly" and "daily" still work
1051+
fn test_backward_compatibility_hourly() {
1052+
// "hourly" should produce a cron where only the minute varies (all other fields are `*`)
10741053
let yaml = generate_schedule_yaml("hourly", "test", &[]).unwrap();
10751054
assert!(yaml.contains("cron:"));
1076-
1077-
let yaml = generate_schedule_yaml("daily", "test", &[]).unwrap();
1078-
assert!(yaml.contains("cron:"));
1055+
// Extract the cron expression from the YAML: ` - cron: "N * * * *"`
1056+
let cron_line = yaml
1057+
.lines()
1058+
.find(|l| l.trim_start().starts_with("- cron:"))
1059+
.expect("YAML should contain a `- cron:` line");
1060+
let cron = cron_line
1061+
.trim()
1062+
.trim_start_matches("- cron:")
1063+
.trim()
1064+
.trim_matches('"');
1065+
let parts: Vec<&str> = cron.split_whitespace().collect();
1066+
assert_eq!(parts.len(), 5, "Hourly cron should have 5 fields");
1067+
// Hour, day-of-month, month, day-of-week must all be `*`
1068+
assert_eq!(parts[1], "*", "Hour field should be * for hourly");
1069+
assert_eq!(parts[2], "*", "Day-of-month field should be * for hourly");
1070+
assert_eq!(parts[3], "*", "Month field should be * for hourly");
1071+
assert_eq!(parts[4], "*", "Day-of-week field should be * for hourly");
1072+
// Minute must be a valid 0-59 integer
1073+
let minute: u32 = parts[0].parse().expect("Minute field should be a number");
1074+
assert!(minute < 60, "Minute should be 0-59");
10791075
}
10801076
}

src/safeoutputs/add_build_tag.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -127,20 +127,20 @@ impl Executor for AddBuildTagResult {
127127
// Compare in u64 space so that ADO build IDs larger than i32::MAX are
128128
// still enforced (the agent-supplied i32 simply cannot match such
129129
// values, which is the desired behavior).
130-
if !config.allow_any_build {
131-
if let Some(current_id) = ctx.build_id {
132-
// self.build_id is validated > 0, so the cast to u64 is exact;
133-
// values that don't fit in i32 simply cannot match current_id.
134-
if self.build_id as u64 != current_id {
135-
return Ok(ExecutionResult::failure(format!(
136-
"Build #{} cannot be tagged: only the current build (#{}) is \
137-
allowed unless 'allow-any-build: true' is configured",
138-
self.build_id, current_id
139-
)));
140-
}
130+
if !config.allow_any_build
131+
&& let Some(current_id) = ctx.build_id
132+
{
133+
// self.build_id is validated > 0, so the cast to u64 is exact;
134+
// values that don't fit in i32 simply cannot match current_id.
135+
if self.build_id as u64 != current_id {
136+
return Ok(ExecutionResult::failure(format!(
137+
"Build #{} cannot be tagged: only the current build (#{}) is \
138+
allowed unless 'allow-any-build: true' is configured",
139+
self.build_id, current_id
140+
)));
141141
}
142-
// If build_id is not set (e.g. local execution), allow any build
143142
}
143+
// If build_id is not set (e.g. local execution), allow any build
144144

145145
// 3. Apply tag prefix if configured
146146
let final_tag = match &config.tag_prefix {

src/safeoutputs/add_pr_comment.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -269,13 +269,13 @@ impl Executor for AddPrCommentResult {
269269
};
270270

271271
// Validate file_path if present
272-
if let Some(ref fp) = self.file_path {
273-
if let Err(e) = validate_file_path(fp) {
274-
return Ok(ExecutionResult::failure(format!(
275-
"Invalid file_path: {}",
276-
e
277-
)));
278-
}
272+
if let Some(ref fp) = self.file_path
273+
&& let Err(e) = validate_file_path(fp)
274+
{
275+
return Ok(ExecutionResult::failure(format!(
276+
"Invalid file_path: {}",
277+
e
278+
)));
279279
}
280280

281281
// Determine the repository name for the API call

src/safeoutputs/queue_build.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,12 @@ impl Executor for QueueBuildResult {
232232
});
233233

234234
// Add template parameters as a JSON string if provided
235-
if let Some(params) = &self.parameters {
236-
if !params.is_empty() {
237-
let params_json = serde_json::to_string(params)
238-
.context("Failed to serialize template parameters")?;
239-
body["parameters"] = serde_json::Value::String(params_json);
240-
}
235+
if let Some(params) = &self.parameters
236+
&& !params.is_empty()
237+
{
238+
let params_json = serde_json::to_string(params)
239+
.context("Failed to serialize template parameters")?;
240+
body["parameters"] = serde_json::Value::String(params_json);
241241
}
242242

243243
// Build the API URL

src/safeoutputs/update_work_item.rs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -465,24 +465,24 @@ impl Executor for UpdateWorkItemResult {
465465
return Ok(result);
466466
}
467467

468-
// Validate agent-provided tags against allowed-tags (if configured)
469-
if let Some(tags) = &self.tags {
470-
if !config.allowed_tags.is_empty() {
471-
let disallowed: Vec<_> = tags
472-
.iter()
473-
.filter(|tag| {
474-
!config
475-
.allowed_tags
476-
.iter()
477-
.any(|pattern| super::tag_matches_pattern(tag, pattern))
478-
})
479-
.collect();
480-
if !disallowed.is_empty() {
481-
return Ok(ExecutionResult::failure(format!(
482-
"Agent-provided tags not in allowed-tags: {}",
483-
disallowed.iter().map(|t| t.as_str()).collect::<Vec<_>>().join(", ")
484-
)));
485-
}
468+
// Validate agent-provided tags against allowed-tags (if configured)
469+
if let Some(tags) = &self.tags
470+
&& !config.allowed_tags.is_empty()
471+
{
472+
let disallowed: Vec<_> = tags
473+
.iter()
474+
.filter(|tag| {
475+
!config
476+
.allowed_tags
477+
.iter()
478+
.any(|pattern| super::tag_matches_pattern(tag, pattern))
479+
})
480+
.collect();
481+
if !disallowed.is_empty() {
482+
return Ok(ExecutionResult::failure(format!(
483+
"Agent-provided tags not in allowed-tags: {}",
484+
disallowed.iter().map(|t| t.as_str()).collect::<Vec<_>>().join(", ")
485+
)));
486486
}
487487
}
488488

src/safeoutputs/upload_build_attachment.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -322,14 +322,14 @@ impl Executor for UploadBuildAttachmentResult {
322322
// Validate name-prefix length before applying. A long prefix would
323323
// be caught later by the final_name.len() > 100 check, but rejecting
324324
// early gives operators a clearer error message.
325-
if let Some(prefix) = &config.name_prefix {
326-
if prefix.len() > 50 {
327-
return Ok(ExecutionResult::failure(format!(
328-
"name-prefix '{}...' is too long ({} chars, max 50)",
329-
prefix.chars().take(20).collect::<String>(),
330-
prefix.len()
331-
)));
332-
}
325+
if let Some(prefix) = &config.name_prefix
326+
&& prefix.len() > 50
327+
{
328+
return Ok(ExecutionResult::failure(format!(
329+
"name-prefix '{}...' is too long ({} chars, max 50)",
330+
prefix.chars().take(20).collect::<String>(),
331+
prefix.len()
332+
)));
333333
}
334334

335335
// Apply name-prefix and re-validate the resulting name's charset (the

src/safeoutputs/upload_pipeline_artifact.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -319,14 +319,14 @@ impl Executor for UploadPipelineArtifactResult {
319319
}
320320

321321
// ── Name-prefix ─────────────────────────────────────────────────
322-
if let Some(prefix) = &config.name_prefix {
323-
if prefix.len() > 50 {
324-
return Ok(ExecutionResult::failure(format!(
325-
"name-prefix '{}...' is too long ({} chars, max 50)",
326-
prefix.chars().take(20).collect::<String>(),
327-
prefix.len()
328-
)));
329-
}
322+
if let Some(prefix) = &config.name_prefix
323+
&& prefix.len() > 50
324+
{
325+
return Ok(ExecutionResult::failure(format!(
326+
"name-prefix '{}...' is too long ({} chars, max 50)",
327+
prefix.chars().take(20).collect::<String>(),
328+
prefix.len()
329+
)));
330330
}
331331
let final_name = match &config.name_prefix {
332332
Some(prefix) => format!("{}{}", prefix, self.artifact_name),

0 commit comments

Comments
 (0)