Skip to content
Merged
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output.
│ │ ├── mod.rs # Pipeline / PipelineBody / PipelineShape root types
│ │ ├── ids.rs # Typed StageId / JobId / StepId newtypes
│ │ ├── step.rs # Step variants (Bash, Task, Checkout, Download, Publish, RawYaml)
│ │ ├── tasks/ # Typed builder structs for built-in ADO tasks (one file per task; new()+typed setters+into_step(); command-enum dispatch for Docker/DotNet/NuGet/Npm/UniversalPackages; typestate builders for PowerShell; docker.rs canonical template)
│ │ ├── tasks/ # Typed builder structs for built-in ADO tasks (one file per task; new()+typed setters+into_step(); command-enum dispatch for Docker/DotNet/NuGet/Npm/UniversalPackages; typestate builders for PowerShell; docker.rs canonical template; tasks/parse.rs reuses the builders as serde schemas to advisory-validate authored front-matter task steps — surfaced as task-input-invalid warnings via ado-aw lint / lint_workflow MCP, NOT via compile)
│ │ ├── job.rs # Job, Pool, TemplateContext, JobVariable
│ │ ├── stage.rs # Stage + external-params wrap
│ │ ├── env.rs # Typed EnvValue (Literal, AdoMacro, PipelineVar, Secret, StepOutput, Coalesce, Concat)
Expand Down
30 changes: 30 additions & 0 deletions docs/front-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,36 @@ parameters: # optional ADO runtime parameters (surfaced in UI
Build the project and run all tests...
```

## Inline step validation (`setup` / `steps` / `post-steps` / `teardown`)

Inline steps are authored as raw Azure DevOps YAML and are emitted into the
generated pipeline **verbatim** (a passthrough). For steps that invoke a
built-in ADO task the compiler also knows (e.g. `CopyFiles@2`, `Docker@2`,
`DotNetCoreCLI@2`, and most other first-party tasks), `ado-aw lint` performs an
**advisory** validation of the `inputs:` mapping against the task's typed
schema — checking for missing required inputs, unknown input keys, bad
constrained values, and (for command/mode tasks) inputs supplied for the wrong
command.

This validation is surfaced through the **lint** channel, not compile:

- Run `ado-aw lint <agent.md>` (add `--json` for machine-readable findings), or
call the `lint_workflow` tool on the author-facing MCP server. Each invalid
step produces a `task-input-invalid` **warning** finding with the offending
task id and which step list it came from.
- It is **warning-only**: findings never fail `lint` (exit code stays 0) and
never affect `compile` or the emitted YAML — the step is always passed through
unchanged.
- A task the compiler does not model, or a non-task step (`bash:`/`script:`/
`checkout:`), produces no finding.

Surfacing this through lint (rather than as a compile-time stderr warning) keeps
the feedback in the structured channel that authoring agents already consume to
check the steps they synthesise, and keeps the in-pipeline integrity recompile
quiet. So adding validation coverage can only ever *surface* authoring mistakes
— it never rejects a workflow that compiled before. See [`ir.md`](ir.md)
(`tasks/parse.rs`) for the mechanism and how to extend coverage.

## Debug-only `ado-aw-debug:`

`ado-aw-debug:` is accepted in front matter for repository dogfooding and
Expand Down
1 change: 1 addition & 0 deletions docs/ir.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Those wrappers are the only place per-target shape (top-level `PipelineShape`, t
- `ids.rs` — typed `StageId`, `JobId`, and `StepId` newtypes. Constructors validate the ADO identifier grammar (`^[A-Za-z_][A-Za-z0-9_]*$`) so invalid names fail at compile time.
- `step.rs` — `Step` and concrete step structs: `BashStep`, `TaskStep`, `CheckoutStep`, `DownloadStep`, and `PublishStep`.
- `tasks/` — typed **builder structs** for built-in ADO tasks (one submodule per task). Each builder exposes `new(<required>)`, typed chained setters for optional inputs (enums for constrained values, `bool` for bool-string inputs), and `into_step() -> TaskStep`; only set fields are emitted. Command-dispatch tasks (`Docker@2`, `DotNetCoreCLI@2`, `NuGetCommand@2`, `Npm@1`, `UniversalPackages@1`) use a command enum with per-variant data so invalid input/command combos are unrepresentable (`tasks/docker.rs` is the canonical template). `PowerShell@2` uses distinct typestate builders (`PowerShellFile` / `PowerShellInline`) instead — mode-specific inputs are only available on the matching typestate builder. Prefer these builders for compiler-generated tasks rather than open-coding `TaskStep::new(...)` with raw string keys at each call site.
- `tasks/parse.rs` — uses the same builder structs *in reverse* to **validate authored front-matter task steps** ("parse, don't validate"): each builder derives `serde::Deserialize` keyed on its ADO input names (`deny_unknown_fields`), so a successful deserialization *is* the validation. `validate_task_step(&Value) -> Option<Result<(), String>>` looks the `task:` up in the `VALIDATORS` registry (`None` = unmodeled task, passed through untouched; `Some(Ok)` = valid; `Some(Err)` = recognized task with bad inputs), and `validate_front_matter_task_steps(...)` runs it over the four authored step lists, returning structured `TaskStepFinding`s. It **never** returns an unrecoverable error. The findings are surfaced through the **lint** channel — `inspect::lint::lint_front_matter_tasks` maps them to `task-input-invalid` warning findings that `ado-aw lint` / the `lint_workflow` MCP tool report (not via `compile`, which never warns and never alters emitted YAML). Command/mode-dispatch tasks supply a bespoke `validate_inputs` that dispatches on their discriminator input before deserializing the matching variant. Adding a task = derive `Deserialize` on the builder + one `VALIDATORS` line.
- `job.rs` — `Job`, `Pool`, job variables, 1ES `templateContext` support, and target-job external `dependsOn` / `condition` wrapping.
- `stage.rs` — `Stage` plus target-stage external `dependsOn` / `condition` wrapping.
- `env.rs` — typed environment values (`EnvValue`) including ADO macros, pipeline variables, secrets, `OutputRef`s, `Coalesce`, macro-form `Concat`, and `RuntimeExpression` (a `$[ ... ]` ADO runtime expression that the lowering pass auto-hoists to a job-level `variables:` entry — ADO does not evaluate `$[ ... ]` inside step `env:`). `RuntimeExpression` is only valid at the top level of a step `env:` value: nesting it inside `Concat` or `Coalesce` is rejected at lower time (the hoist pass walks only the top level, so a nested occurrence would emit a dangling `$(AwRtExpr_…)` macro). A `Literal` or `RawYamlScalar(String)` smuggling a raw `$[ ... ]` into a step `env:` value (whether at the top level or nested inside a `Concat`) is likewise rejected at lower time — ADO passes such scalars verbatim, so the typed `RuntimeExpression` variant must be used instead.
Expand Down
14 changes: 14 additions & 0 deletions prompts/create-ado-agentic-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,19 @@ ado-aw compile <path/to/agent.md> -o <path/to/pipeline.lock.yml>

This generates a `.lock.yml` pipeline file. Both the source `.md` and generated `.lock.yml` must be committed together. The compiler also writes/updates a `.gitattributes` file at the repository root so compiled pipelines are marked `linguist-generated=true merge=ours`.

### Lint authored task steps

If the workflow includes raw Azure DevOps `task:` steps in `setup:`, `steps:`, `post-steps:`, or `teardown:`, run the linter and fix any findings before committing:

```bash
ado-aw lint <path/to/agent.md> --json
```

The compiler emits those steps **verbatim** and does **not** validate their inputs — invalid task inputs (a missing required input, an unknown/misspelled input key, a bad enum value, or an input for the wrong command of a command-dispatch task like `Docker@2`) surface **only** through `ado-aw lint` as `task-input-invalid` warning findings. (The same checks are available via the `lint_workflow` tool on the author-facing MCP server.)

> **Read the findings, not the exit code.** These are advisory *warnings*, so `ado-aw lint` still exits `0` — parse the JSON `findings` array and address any entry whose `code` is `task-input-invalid` (its `location.step` names the offending task and `location.job` the step list). Unmodeled tasks and non-`task:` steps (`bash:`/`script:`) are never flagged, so a clean lint means every recognized task's inputs are well-formed.


If the `ado-aw` CLI is not installed or not available on `PATH`, guide the user to download it from:
https://github.com/githubnext/ado-aw/releases

Expand Down Expand Up @@ -808,3 +821,4 @@ safe-outputs:
- **No direct writes**: All mutations go through safe outputs — the agent cannot push code or call write APIs directly.
- **Compile before committing**: Always compile with `ado-aw compile` and commit both the `.md` source and generated `.lock.yml` together.
- **Check validation**: The compiler validates front-matter fields and emits errors for invalid configurations (e.g., conflicting filter rules, missing required fields like `comment-on-work-item.target`). Write-bearing safe outputs do **not** require `permissions.write` — the executor defaults to `$(System.AccessToken)`.
- **Lint raw task steps**: When you author `task:` steps, run `ado-aw lint <file> --json` and fix any `task-input-invalid` findings — the compiler passes those steps through unchecked, so lint is the only place invalid task inputs surface.
64 changes: 52 additions & 12 deletions src/compile/ir/tasks/archive_files.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
//! Typed builder for `ArchiveFiles@2`.

use super::common::bool_input;
use super::common::{bool_input, de_opt_bool_flex};
use crate::compile::ir::step::TaskStep;
use serde::Deserialize;

/// Archive format for [`ArchiveFiles`] (`archiveType` input).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum ArchiveType {
#[serde(rename = "zip")]
Zip,
#[serde(rename = "7z")]
SevenZip,
#[serde(rename = "tar")]
Tar,
#[serde(rename = "wim")]
Wim,
}

Expand All @@ -26,11 +31,15 @@ impl ArchiveType {

/// Tar compression for [`ArchiveFiles`] (`tarCompression` input, only when
/// `archiveType = tar`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum TarCompression {
#[serde(rename = "gz")]
Gz,
#[serde(rename = "bz2")]
Bz2,
#[serde(rename = "xz")]
Xz,
#[serde(rename = "none")]
None,
}

Expand All @@ -48,13 +57,19 @@ impl TarCompression {

/// 7-Zip compression level for [`ArchiveFiles`] (`sevenZipCompression` input,
/// only when `archiveType = 7z`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum SevenZipCompression {
#[serde(rename = "ultra")]
Ultra,
#[serde(rename = "maximum")]
Maximum,
#[serde(rename = "normal")]
Normal,
#[serde(rename = "fast")]
Fast,
#[serde(rename = "fastest")]
Fastest,
#[serde(rename = "none")]
None,
}

Expand All @@ -78,26 +93,42 @@ impl SevenZipCompression {
///
/// ADO task reference:
/// <https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/archive-files-v2>
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ArchiveFiles {
#[serde(rename = "rootFolderOrFile")]
root_folder_or_file: String,
#[serde(rename = "archiveFile")]
archive_file: String,
#[serde(rename = "archiveType", default)]
archive_type: Option<ArchiveType>,
#[serde(
rename = "includeRootFolder",
default,
deserialize_with = "de_opt_bool_flex"
)]
include_root_folder: Option<bool>,
#[serde(
rename = "replaceExistingArchive",
default,
deserialize_with = "de_opt_bool_flex"
)]
replace_existing_archive: Option<bool>,
#[serde(rename = "sevenZipCompression", default)]
seven_zip_compression: Option<SevenZipCompression>,
#[serde(rename = "tarCompression", default)]
tar_compression: Option<TarCompression>,
#[serde(default, deserialize_with = "de_opt_bool_flex")]
verbose: Option<bool>,
#[serde(default, deserialize_with = "de_opt_bool_flex")]
quiet: Option<bool>,
#[serde(skip)]
display_name: Option<String>,
}

impl ArchiveFiles {
/// Required inputs: `rootFolderOrFile` and `archiveFile`.
pub fn new(
root_folder_or_file: impl Into<String>,
archive_file: impl Into<String>,
) -> Self {
pub fn new(root_folder_or_file: impl Into<String>, archive_file: impl Into<String>) -> Self {
Self {
root_folder_or_file: root_folder_or_file.into(),
archive_file: archive_file.into(),
Expand Down Expand Up @@ -223,8 +254,14 @@ mod tests {
.include_root_folder(false)
.into_step();
assert_eq!(t.inputs.get("archiveType").map(String::as_str), Some("tar"));
assert_eq!(t.inputs.get("tarCompression").map(String::as_str), Some("gz"));
assert_eq!(t.inputs.get("includeRootFolder").map(String::as_str), Some("false"));
assert_eq!(
t.inputs.get("tarCompression").map(String::as_str),
Some("gz")
);
assert_eq!(
t.inputs.get("includeRootFolder").map(String::as_str),
Some("false")
);
}

#[test]
Expand All @@ -234,6 +271,9 @@ mod tests {
.seven_zip_compression(SevenZipCompression::Ultra)
.into_step();
assert_eq!(t.inputs.get("archiveType").map(String::as_str), Some("7z"));
assert_eq!(t.inputs.get("sevenZipCompression").map(String::as_str), Some("ultra"));
assert_eq!(
t.inputs.get("sevenZipCompression").map(String::as_str),
Some("ultra")
);
}
}
Loading
Loading