diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 37a0c22d..ddc0859c 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -5,6 +5,7 @@ on: paths: - "src/**" - "tests/**" + - "docs/template-markers.md" - "Cargo.toml" - "Cargo.lock" diff --git a/docs/template-markers.md b/docs/template-markers.md index cf0246e2..23e48515 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -455,21 +455,6 @@ Generates an `env:` block for the "Start MCP Gateway (MCPG)" pipeline step, forw When no extensions require pipeline variables, this marker is replaced with an empty string and the MCPG step has no `env:` block. -## {{ mcp_client_config }} *(obsolete)* - -**Removed in recent versions.** The Copilot CLI `mcp-config.json` is no longer generated at compile time. Instead, it is derived at **pipeline runtime** from MCPG's actual gateway output, matching gh-aw's `convert_gateway_config_copilot.cjs` pattern. - -The "Start MCP Gateway (MCPG)" pipeline step: -1. Redirects MCPG's stdout to `gateway-output.json` -2. Waits for the health check and for valid JSON output -3. Transforms the output with a Python script that: - - Rewrites URLs from `127.0.0.1` → `host.docker.internal` (AWF container loopback vs host) - - Ensures `tools: ["*"]` on each server entry (Copilot CLI requirement) - - Preserves all other fields (headers, type, etc.) -4. Writes the result to `/tmp/awf-tools/mcp-config.json` and `$HOME/.copilot/mcp-config.json` - -This ensures the Copilot CLI config reflects MCPG's actual runtime state rather than a compile-time prediction. - ## {{ allowed_domains }} Should be replaced with the comma-separated domain list for AWF's `--allow-domains` flag. The list includes: @@ -607,10 +592,6 @@ Should be replaced with the MCPG listening port (defined as `MCPG_PORT` constant Should be replaced with the domain the AWF-sandboxed agent uses to reach MCPG on the host (defined as `MCPG_DOMAIN` constant in `src/compile/common.rs`, currently `host.docker.internal`). Used in the pipeline to set the `MCP_GATEWAY_DOMAIN` ADO variable. Docker's `host.docker.internal` resolves to the host loopback from inside containers. -## {{ copilot_version }} - -**Removed.** This marker has been absorbed into `{{ engine_install_steps }}`. The `COPILOT_CLI_VERSION` constant now lives in `src/engine.rs` and is used internally by `Engine::install_steps()`. The version can be overridden per-agent via `engine: { id: copilot, version: "..." }` in front matter. - ## 1ES-Specific Template Markers The 1ES target uses the same template markers as standalone, plus the 1ES-specific `extends:` / `stages:` / `templateContext` wrapping. The 1ES template includes `templateContext.type: buildJob` for all jobs, and the pool is specified at the top-level `parameters.pool` rather than per-job. diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 8eb503b1..ae68f40d 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -4810,3 +4810,86 @@ fn test_example_dogfood_failure_reporter_structure() { "Example should target githubnext/ado-aw" ); } + +/// Test that every `{{ marker }}` used in `src/data/*.yml` has a corresponding +/// `## {{ marker }}` heading in `docs/template-markers.md`. +/// +/// This is the CI/docs marker-drift guard: if a marker is added to a template +/// without updating the docs, this test fails. +#[test] +fn test_template_marker_docs_coverage() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let data_dir = manifest_dir.join("src").join("data"); + let docs_file = manifest_dir.join("docs").join("template-markers.md"); + + // --- collect markers from src/data/*.yml --- + let yml_entries = fs::read_dir(&data_dir) + .unwrap_or_else(|e| panic!("Cannot read {}: {e}", data_dir.display())); + + let mut yml_markers: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for entry in yml_entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("yml") { + continue; + } + let content = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Cannot read {}: {e}", path.display())); + for cap in regex_captures_markers(&content) { + yml_markers.insert(cap); + } + } + + // --- collect documented marker headings from docs/template-markers.md --- + let docs = fs::read_to_string(&docs_file) + .unwrap_or_else(|e| panic!("Cannot read {}: {e}", docs_file.display())); + + let mut documented: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for line in docs.lines() { + // Match lines like: ## {{ marker_name }} + if let Some(rest) = line.strip_prefix("## {{ ") + && let Some(name) = rest.split("}}").next() + { + documented.insert(name.trim().to_string()); + } + } + + // Every marker that appears in the yml files must have a docs heading. + let mut missing: Vec = Vec::new(); + for marker in &yml_markers { + if !documented.contains(marker.as_str()) { + missing.push(format!("{{{{ {marker} }}}}")); + } + } + + assert!( + missing.is_empty(), + "The following template markers appear in src/data/*.yml but have no \ + '## {{{{ marker }}}}' heading in docs/template-markers.md — add docs or \ + update the marker name:\n {}", + missing.join("\n ") + ); +} + +/// Extract all `{{ name }}` marker names from `content` (excluding `${{ }}` ADO expressions). +fn regex_captures_markers(content: &str) -> Vec { + let mut results = Vec::new(); + let mut s: &str = content; + while let Some(start) = s.find("{{ ") { + // Skip ADO ${{ }} expressions + if start > 0 && s.as_bytes().get(start - 1) == Some(&b'$') { + s = &s[start + 3..]; + continue; + } + let after = &s[start + 3..]; + if let Some(end) = after.find("}}") { + let name = after[..end].trim().to_string(); + if !name.is_empty() { + results.push(name); + } + s = &after[end + 2..]; + } else { + break; + } + } + results +}