diff --git a/.gitattributes b/.gitattributes
index 4ac9e4f8..bfdb5422 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -17,9 +17,4 @@ tests/fixtures/runtime_imports_author_marker_stage.lock.yml linguist-generated=t
tests/fixtures/runtime_imports_job.lock.yml linguist-generated=true merge=ours text eol=lf
tests/fixtures/runtime_imports_stage.lock.yml linguist-generated=true merge=ours text eol=lf
tests/fixtures/stage-agent.lock.yml linguist-generated=true merge=ours text eol=lf
-tests/safe-outputs/azure-cli.lock.yml linguist-generated=true merge=ours text eol=lf
-tests/safe-outputs/canary.lock.yml linguist-generated=true merge=ours text eol=lf
-tests/safe-outputs/janitor.lock.yml linguist-generated=true merge=ours text eol=lf
-tests/safe-outputs/noop-target.lock.yml linguist-generated=true merge=ours text eol=lf
-tests/safe-outputs/smoke-failure-reporter.lock.yml linguist-generated=true merge=ours text eol=lf
# END ado-aw managed
diff --git a/.github/workflows/recompile-safe-output-fixtures.lock.yml b/.github/workflows/recompile-safe-output-fixtures.lock.yml
deleted file mode 100644
index 8c01be36..00000000
--- a/.github/workflows/recompile-safe-output-fixtures.lock.yml
+++ /dev/null
@@ -1,1587 +0,0 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"570fadb919958e5139355ad7f9dea10661954f460e10cfc5c79238e163be9efc","body_hash":"09ab817be62be5e8bf9912909ac1fedcbaf36e8c048cdc21c1a8ef7cddee3b7c","compiler_version":"v0.84.0","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.75"}}
-# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"f3ca20900e2363607992fb61b46fc687d4b56ba3","version":"v0.84.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42","digest":"sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42","digest":"sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42","digest":"sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.6","digest":"sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748","pinned_image":"ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748"},{"image":"ghcr.io/github/github-mcp-server:v1.7.0","digest":"sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308","pinned_image":"ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308"}]}
-# This file was automatically generated by gh-aw (v0.84.0). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
-#
-# ___ _ _
-# / _ \ | | (_)
-# | |_| | __ _ ___ _ __ | |_ _ ___
-# | _ |/ _` |/ _ \ '_ \| __| |/ __|
-# | | | | (_| | __/ | | | |_| | (__
-# \_| |_/\__, |\___|_| |_|\__|_|\___|
-# __/ |
-# _ _ |___/
-# | | | | / _| |
-# | | | | ___ _ __ _ __| |_| | _____ ____
-# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
-# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
-# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
-#
-#
-# To update this file, edit the corresponding .md file and run:
-# gh aw compile
-# Not all edits will cause changes to this file.
-#
-# For more information: https://github.github.com/gh-aw/introduction/overview/
-#
-# Recompiles every tests/safe-outputs/*.lock.yml fixture using the latest released ado-aw binary, then opens a single PR if anything changed.
-#
-# Secrets used:
-# - COPILOT_GITHUB_TOKEN
-# - GH_AW_CI_TRIGGER_TOKEN
-# - GH_AW_GITHUB_MCP_SERVER_TOKEN
-# - GH_AW_GITHUB_TOKEN
-# - GITHUB_TOKEN
-#
-# Custom actions used:
-# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
-# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
-# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
-# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@f3ca20900e2363607992fb61b46fc687d4b56ba3 # v0.84.0
-#
-# Container images used:
-# - ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b
-# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607
-# - ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0
-# - ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c
-# - ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748
-# - ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308
-
-name: "Recompile safe-output fixtures"
-on:
- # bots: # Bots processed as bot check in pre-activation job
- # - github-actions[bot] # Bots processed as bot check in pre-activation job
- release:
- types:
- - published
- workflow_dispatch:
- inputs:
- aw_context:
- default: ""
- description: "Agent caller context (used internally by Agentic Workflows)."
- required: false
- type: string
- version:
- description: ado-aw release tag to recompile against (e.g. v0.16.0). Leave blank to use the latest published release.
- required: false
- type: string
-
-permissions: {}
-
-concurrency:
- group: "gh-aw-${{ github.workflow }}"
-
-run-name: "Recompile safe-output fixtures"
-
-jobs:
- activation:
- needs: pre_activation
- if: needs.pre_activation.outputs.activated == 'true'
- runs-on: ubuntu-slim
- permissions:
- actions: read
- contents: read
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- comment_id: ""
- comment_repo: ""
- engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
- lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
- model: ${{ steps.generate_aw_info.outputs.model }}
- oauth_token_check_failed: ${{ steps.check-oauth-tokens.outputs.oauth_token_check_failed == 'true' }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@f3ca20900e2363607992fb61b46fc687d4b56ba3 # v0.84.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/recompile-safe-output-fixtures.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.75"
- GH_AW_INFO_AWF_VERSION: "v0.27.42"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Generate agentic run info
- id: generate_aw_info
- env:
- GH_AW_INFO_ENGINE_ID: "copilot"
- GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
- GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_INFO_VERSION: "1.0.75"
- GH_AW_INFO_AGENT_VERSION: "1.0.75"
- GH_AW_INFO_CLI_VERSION: "v0.84.0"
- GH_AW_INFO_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_INFO_EXPERIMENTAL: "false"
- GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
- GH_AW_INFO_STAGED: "false"
- GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github","dev.azure.com","learn.microsoft.com"]'
- GH_AW_INFO_FIREWALL_ENABLED: "true"
- GH_AW_INFO_AWF_VERSION: "v0.27.42"
- GH_AW_INFO_AWMG_VERSION: ""
- GH_AW_INFO_FIREWALL_TYPE: "squid"
- GH_AW_COMPILED_STRICT: "true"
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
- await main(core, context);
- - name: Check for OAuth tokens
- id: check-oauth-tokens
- run: bash "${RUNNER_TEMP}/gh-aw/actions/check_oauth_tokens.sh"
- env:
- COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- - name: Checkout .github and .agents folders
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- sparse-checkout: |
- .github
- .agents
- .antigravity
- .claude
- .codex
- .gemini
- .opencode
- .pi
- sparse-checkout-cone-mode: true
- fetch-depth: 1
- - name: Save agent config folders for base branch restoration
- env:
- GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi"
- GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
- # poutine:ignore untrusted_checkout_exec
- run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
- - name: Check workflow lock file
- id: check-lock-file
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_WORKFLOW_FILE: "recompile-safe-output-fixtures.lock.yml"
- GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
- await main();
- - name: Check compile-agentic version
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_COMPILED_VERSION: "v0.84.0"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs');
- await main();
- - name: Log runtime features
- if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh"
- - name: Create prompt with built-in context
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- # poutine:ignore untrusted_checkout_exec
- run: |
- bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
- {
- cat << 'GH_AW_PROMPT_47b47d6c649c28a8_EOF'
-
- GH_AW_PROMPT_47b47d6c649c28a8_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_47b47d6c649c28a8_EOF'
-
- Tools: create_pull_request, close_pull_request(max:5), missing_tool, missing_data, noop
- GH_AW_PROMPT_47b47d6c649c28a8_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md"
- cat << 'GH_AW_PROMPT_47b47d6c649c28a8_EOF'
-
- GH_AW_PROMPT_47b47d6c649c28a8_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_47b47d6c649c28a8_EOF'
-
- The following GitHub context information is available for this workflow:
- {{#if github.actor}}
- - **actor**: __GH_AW_GITHUB_ACTOR__
- {{/if}}
- {{#if github.repository}}
- - **repository**: __GH_AW_GITHUB_REPOSITORY__
- {{/if}}
- {{#if github.workspace}}
- - **workspace**: __GH_AW_GITHUB_WORKSPACE__
- {{/if}}
- {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}
- - **issue-number**: #__GH_AW_EXPR_802A9F6A__
- {{/if}}
- {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}
- - **discussion-number**: #__GH_AW_EXPR_1A3A194A__
- {{/if}}
- {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}
- - **pull-request-number**: #__GH_AW_EXPR_463A214A__
- {{/if}}
- {{#if github.event.comment.id || github.aw.context.comment_id}}
- - **comment-id**: __GH_AW_EXPR_FF1D34CE__
- {{/if}}
- {{#if github.run_id}}
- - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
- {{/if}}
-
-
- GH_AW_PROMPT_47b47d6c649c28a8_EOF
- cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_47b47d6c649c28a8_EOF'
-
- {{#runtime-import .github/workflows/recompile-safe-output-fixtures.md}}
- GH_AW_PROMPT_47b47d6c649c28a8_EOF
- } > "$GH_AW_PROMPT"
- - name: Interpolate variables and render templates
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_ENGINE_ID: "copilot"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
- await main();
- - name: Substitute placeholders
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
- GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
- GH_AW_GITHUB_ACTOR: ${{ github.actor }}
- GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
- GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
- GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_MCP_CLI_SERVERS_LIST: "- `github` — run `github --help` to see available tools\n- `safeoutputs` — run `safeoutputs --help` to see available tools"
- GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
-
- const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
-
- // Call the substitution function
- return await substitutePlaceholders({
- file: process.env.GH_AW_PROMPT,
- substitutions: {
- GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A,
- GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A,
- GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A,
- GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE,
- GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
- GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
- GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST,
- GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
- }
- });
- - name: Validate prompt placeholders
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- # poutine:ignore untrusted_checkout_exec
- run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
- - name: Print prompt
- env:
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- # poutine:ignore untrusted_checkout_exec
- run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh"
- - name: Upload activation artifact
- if: success()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: activation
- include-hidden-files: true
- path: |
- /tmp/gh-aw/aw_info.json
- /tmp/gh-aw/models.json
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/aw-prompts/prompt-template.txt
- /tmp/gh-aw/aw-prompts/prompt-import-tree.json
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/base
- /tmp/gh-aw/.github/agents
- /tmp/gh-aw/.github/skills
- if-no-files-found: ignore
- retention-days: 1
-
- agent:
- needs: activation
- runs-on: ubuntu-latest
- permissions:
- contents: read
- copilot-requests: write
- issues: read
- pull-requests: read
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
- queue: max
- env:
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- GH_AW_ASSETS_ALLOWED_EXTS: ""
- GH_AW_ASSETS_BRANCH: ""
- GH_AW_ASSETS_MAX_SIZE_KB: 0
- GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_WORKFLOW_ID_SANITIZED: recompilesafeoutputfixtures
- outputs:
- agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
- ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
- aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
- ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
- checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
- effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
- has_patch: ${{ steps.collect_output.outputs.has_patch }}
- http_400_response_error: ${{ steps.detect-agent-errors.outputs.http_400_response_error || 'false' }}
- inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
- invocation_cap_exceeded: ${{ steps.detect-agent-errors.outputs.invocation_cap_exceeded || 'false' }}
- mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
- missing_model_pricing_error: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_error || 'false' }}
- missing_model_pricing_model_name: ${{ steps.detect-agent-errors.outputs.missing_model_pricing_model_name || '' }}
- model: ${{ needs.activation.outputs.model }}
- model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
- output: ${{ steps.collect_output.outputs.output }}
- output_types: ${{ steps.collect_output.outputs.output_types }}
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@f3ca20900e2363607992fb61b46fc687d4b56ba3 # v0.84.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/recompile-safe-output-fixtures.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.75"
- GH_AW_INFO_AWF_VERSION: "v0.27.42"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Set runtime paths
- id: set-runtime-paths
- run: |
- {
- echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl"
- echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
- echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
- } >> "$GITHUB_OUTPUT"
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - name: Create gh-aw temp directory
- run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
- - name: Configure gh CLI for GitHub Enterprise
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
- env:
- GH_TOKEN: ${{ github.token }}
- - name: Download activation artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: activation
- path: /tmp/gh-aw
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Checkout PR branch
- id: checkout-pr
- if: |
- github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
- await main();
- - name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.75
- env:
- GH_HOST: github.com
- - name: Install AWF binary
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.42 --rootless
- - name: Determine automatic lockdown mode for GitHub MCP Server
- id: determine-automatic-lockdown
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
- env:
- GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- with:
- script: |
- const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
- await determineAutomaticLockdown(github, context, core);
- - name: Restore agent config folders from base branch
- if: steps.checkout-pr.outcome == 'success'
- env:
- GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi"
- GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
- run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh"
- - name: Restore inline sub-agents from activation artifact
- env:
- GH_AW_SUB_AGENT_DIR: ".github/agents"
- GH_AW_SUB_AGENT_EXT: ".agent.md"
- run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh"
- - name: Restore inline skills from activation artifact
- env:
- GH_AW_SKILL_DIR: ".github/skills"
- run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh"
- - name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607 ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0 ghcr.io/github/gh-aw-mcpg:v0.4.6@sha256:fecabec51bbc41f2ad61076d6bcd9a36ef23b142e672a444e054d37fc29de93c ghcr.io/github/gh-aw-node@sha256:a8082161d7dceda14b68f32eb39d0eaa96b825d07f5895b096afab9d9e0c7748 ghcr.io/github/github-mcp-server:v1.7.0@sha256:c491ffdf6f4c85cb5397021bc655edb8ab825c6f5f568e7597d77a1bd7c4d308
- - name: Generate Safe Outputs Config
- run: |
- mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
- mkdir -p /tmp/gh-aw/safeoutputs
- mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_2b73ed2f837c5314_EOF'
- {"close_pull_request":{"max":5,"required_title_prefix":"chore(workflows): recompile safe-output fixtures","target":"*"},"create_pull_request":{"allowed_files":["tests/safe-outputs/*.lock.yml","tests/safe-outputs/**/*.lock.yml"],"max":1,"max_patch_files":100,"max_patch_size":4096,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review","title_prefix":"chore(workflows): "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
- GH_AW_SAFE_OUTPUTS_CONFIG_2b73ed2f837c5314_EOF
- - name: Generate Safe Outputs Tools
- env:
- GH_AW_TOOLS_META_JSON: |
- {
- "description_suffixes": {
- "close_pull_request": " CONSTRAINTS: Maximum 5 pull request(s) can be closed. Target: *. Only PRs with title prefix \"chore(workflows): recompile safe-output fixtures\" can be closed.",
- "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"chore(workflows): \"."
- },
- "repo_params": {},
- "dynamic_tools": []
- }
- GH_AW_VALIDATION_JSON: |
- {
- "close_pull_request": {
- "defaultMax": 1,
- "fields": {
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "pull_request_number": {
- "optionalPositiveInteger": true
- },
- "repo": {
- "type": "string",
- "maxLength": 256
- }
- }
- },
- "create_pull_request": {
- "defaultMax": 1,
- "fields": {
- "base": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- },
- "body": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "branch": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "draft": {
- "type": "boolean"
- },
- "labels": {
- "type": "array",
- "itemType": "string",
- "itemSanitize": true,
- "itemMaxLength": 128
- },
- "repo": {
- "type": "string",
- "maxLength": 256
- },
- "title": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "missing_data": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "context": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "data_type": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- },
- "reason": {
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- }
- }
- },
- "missing_tool": {
- "defaultMax": 20,
- "fields": {
- "alternatives": {
- "type": "string",
- "sanitize": true,
- "maxLength": 512
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 256
- },
- "tool": {
- "type": "string",
- "sanitize": true,
- "maxLength": 128
- }
- }
- },
- "noop": {
- "defaultMax": 1,
- "fields": {
- "message": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- }
- }
- },
- "report_incomplete": {
- "defaultMax": 5,
- "fields": {
- "details": {
- "type": "string",
- "sanitize": true,
- "maxLength": 65000
- },
- "reason": {
- "required": true,
- "type": "string",
- "sanitize": true,
- "maxLength": 1024
- }
- }
- }
- }
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
- await main();
- - name: Start MCP Gateway
- id: start-mcp-gateway
- env:
- GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST: ${{ vars.GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST || 'true' }}
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_CONFIG_PATH }}
- GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS_TOOLS_PATH }}
- GH_AW_SINK_VISIBILITY: ${{ steps.determine-automatic-lockdown.outputs.visibility }}
- GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
- GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -eo pipefail
- mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config"
-
- # Export gateway environment variables for MCP config and gateway script
- export MCP_GATEWAY_PORT="8080"
- export MCP_GATEWAY_DOMAIN="awmg-mcpg"
- export MCP_GATEWAY_HOST_DOMAIN="localhost"
- MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
- echo "::add-mask::${MCP_GATEWAY_API_KEY}"
- export MCP_GATEWAY_API_KEY
- export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
- mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
- export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
- export DEBUG="*"
-
- export GH_AW_ENGINE="copilot"
- MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0')
- MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0')
- source "${RUNNER_TEMP}/gh-aw/actions/resolve_docker_socket_gid.sh"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GH_AW_SINK_VISIBILITY -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.6'
-
- mkdir -p "$HOME/.copilot"
- GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
- cat << GH_AW_MCP_CONFIG_ad4bc31bafefa68c_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
- {
- "mcpServers": {
- "github": {
- "type": "stdio",
- "container": "ghcr.io/github/github-mcp-server:v1.7.0",
- "env": {
- "GITHUB_FEATURES": "fields_param",
- "GITHUB_HOST": "${GITHUB_SERVER_URL}",
- "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_SERVER_TOKEN}",
- "GITHUB_READ_ONLY": "1",
- "GITHUB_TOOLSETS": "context,repos,issues,pull_requests"
- },
- "guard-policies": {
- "allow-only": {
- "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY",
- "repos": "$GITHUB_MCP_GUARD_REPOS"
- }
- }
- },
- "safeoutputs": {
- "type": "stdio",
- "container": "ghcr.io/github/gh-aw-node",
- "mounts": ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "${RUNNER_TEMP}/gh-aw/safeoutputs:${RUNNER_TEMP}/gh-aw/safeoutputs:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"],
- "args": ["-w", "\${GITHUB_WORKSPACE}"],
- "entrypoint": "sh",
- "entrypointArgs": ["-c", "sh ${RUNNER_TEMP}/gh-aw/safeoutputs/start_safe_outputs_mcp.sh"],
- "env": {
- "DEBUG": "*",
- "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}",
- "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}",
- "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}",
- "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}",
- "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}",
- "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}",
- "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}",
- "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}",
- "GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST": "\${GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST}",
- "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}",
- "GITHUB_SHA": "\${GITHUB_SHA}",
- "GITHUB_TOKEN": "\${GITHUB_TOKEN}",
- "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}",
- "RUNNER_TEMP": "\${RUNNER_TEMP}"
- },
- "guard-policies": {
- "write-sink": {
- "accept": [
- "*"
- ],
- "sink-visibility": "${GH_AW_SINK_VISIBILITY}"
- }
- }
- }
- },
- "gateway": {
- "port": $MCP_GATEWAY_PORT,
- "domain": "${MCP_GATEWAY_DOMAIN}",
- "apiKey": "${MCP_GATEWAY_API_KEY}",
- "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}",
- "startupTimeout": 120
- }
- }
- GH_AW_MCP_CONFIG_ad4bc31bafefa68c_EOF
- - name: Mount MCP servers as CLIs
- id: mount-mcp-clis
- continue-on-error: true
- env:
- MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
- MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }}
- MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs');
- await main();
- - name: Clean credentials
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh"
- - name: Audit pre-agent workspace
- id: pre_agent_audit
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh"
- - name: Execute GitHub Copilot CLI
- id: agentic_execution
- # Copilot CLI tool arguments (sorted):
- timeout-minutes: 20
- run: |
- set -o pipefail
- printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
- mkdir -p "$HOME/.copilot"
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
- export XDG_CONFIG_HOME="$HOME"
- export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"
- touch /tmp/gh-aw/agent-step-summary.md
- GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
- export GH_AW_NODE_BIN
- export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
- (umask 177 && touch /tmp/gh-aw/agent-stdio.log)
- # shellcheck disable=SC2016
- printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.27.42/awf-config.schema.json","network":{"allowDomains":["*.githubusercontent.com","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","codeload.github.com","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","dev.azure.com","docs.github.com","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","github.blog","github.com","github.githubassets.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.com","learn.microsoft.com","lfs.github.com","objects.githubusercontent.com","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","patch-diff.githubusercontent.com","patchdiff.githubusercontent.com","ppa.launchpad.net","raw.githubusercontent.com","registry.npmjs.org","s.symcb.com","s.symcd.com","security.ubuntu.com","telemetry.enterprise.githubcopilot.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com"],"isolation":true,"topologyAttach":["awmg-mcpg"]},"apiProxy":{"enabled":true,"maxRuns":500,"maxCacheMisses":5,"models":{"agent":["sonnet-6x","gpt-5.4","gpt-5.5","gpt-5.6","gpt-5.3","gemini-pro","any"],"antigravity":["copilot/antigravity*","google/antigravity*","gemini/antigravity*"],"any":["copilot/*","anthropic/*","openai/*","google/*","gemini/*"],"auto":["copilot/auto","large"],"claude":["agent"],"codex":["agent"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex","kimi"],"computer-use":["copilot/*computer-use*","google/*computer-use*","gemini/*computer-use*","openai/*computer-use*"],"copilot":["agent"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"fable":["copilot/*fable*","anthropic/*fable*"],"gemini":["agent"],"gemini-3-flash":["copilot/gemini-3*flash*","google/gemini-3*flash*","gemini/gemini-3*flash*"],"gemini-3-pro":["copilot/gemini-3*pro*","google/gemini-3*pro*","google/nano-banana*","gemini/gemini-3*pro*"],"gemini-3.1-flash":["copilot/gemini-3.1*flash*","google/gemini-3.1*flash*","gemini/gemini-3.1*flash*"],"gemini-3.1-pro":["copilot/gemini-3.1*pro*","google/gemini-3.1*pro*","gemini/gemini-3.1*pro*"],"gemini-3.5-flash":["copilot/gemini-3.5*flash*","google/gemini-3.5*flash*","gemini/gemini-3.5*flash*"],"gemini-3.6-flash":["copilot/gemini-3.6*flash*","google/gemini-3.6*flash*","gemini/gemini-3.6*flash*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-omni":["copilot/gemini-omni*","google/gemini-omni*","gemini/gemini-omni*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"gpt-5.1":["copilot/gpt-5.1*","openai/gpt-5.1*"],"gpt-5.2":["copilot/gpt-5.2*","openai/gpt-5.2*"],"gpt-5.3":["copilot/gpt-5.3*","openai/gpt-5.3*"],"gpt-5.4":["copilot/gpt-5.4*","openai/gpt-5.4*"],"gpt-5.5":["copilot/gpt-5.5*","openai/gpt-5.5*"],"gpt-5.6":["copilot/gpt-5.6*","openai/gpt-5.6*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"image-generation":["copilot/gpt-image*","openai/gpt-image*","openai/chatgpt-image*","copilot/gemini-*image*","google/gemini-*image*","gemini/gemini-*image*","google/imagen*"],"kimi":["copilot/kimi*","openai/kimi*"],"kiwi":["copilot/kiwi*","openai/kiwi*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"lyria":["google/lyria*","gemini/lyria*","copilot/lyria*"],"mai-code":["copilot/MAI-Code*","copilot/mai-code*","openai/MAI-Code*"],"mai-code-1-flash-picker":["copilot/MAI-Code-1-Flash-picker*","copilot/mai-code-1-flash-picker*","openai/MAI-Code-1-Flash-picker*"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"nano-banana":["copilot/nano-banana*","google/nano-banana*","gemini/nano-banana*"],"opus":["copilot/*opus*","anthropic/*opus*"],"opusplan":["opus?effort=high"],"raptor-mini":["copilot/raptor*","openai/raptor*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"robotics":["copilot/*robotics*","google/*robotics*","gemini/*robotics*"],"small":["mini"],"small-agent":["haiku","gpt-5-mini","gemini-flash"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"sonnet-6x":["copilot/*sonnet-4.5*","copilot/*sonnet-4.6*","copilot/*sonnet-5*","copilot/*sonnet-4-5-*","anthropic/*sonnet-4-5-*","copilot/*sonnet-4-6*","anthropic/*sonnet-4-6*","anthropic/*sonnet-5*"],"summarization":["haiku","gpt-5-mini","gemini-flash-lite","mini"],"veo":["google/veo*","gemini/veo*"],"vision":["copilot/gemini-*image*","google/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.27.42,squid=sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0,agent=sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b,agent-act=sha256:a14ad974484aa518aab83d40f3f141175dfd171d3745e01c092375b970f73a20,api-proxy=sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607,cli-proxy=sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f"},"logging":{"proxyLogsDir":"/tmp/gh-aw/sandbox/firewall/logs","auditDir":"/tmp/gh-aw/sandbox/firewall/audit"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
- export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST=""
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST="${DOCKER_HOST}"
- fi
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_CHROOT_BINARIES_SOURCE_PATH="${RUNNER_TEMP}/gh-aw" GH_AW_CHROOT_IDENTITY_HOME="${RUNNER_TEMP}/gh-aw/home" node "${RUNNER_TEMP}/gh-aw/actions/patch_awf_chroot_config.cjs"
- fi
- GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
- if [ -d "$GH_AW_TOOL_CACHE" ]; then
- if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
- GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
- fi
- fi
- # shellcheck disable=SC1003,SC2016,SC2086
- awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --skip-pull \
- -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
- env:
- AWF_REFLECT_ENABLED: 1
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
- COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_LLM_PROVIDER: github
- GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
- GH_AW_PHASE: agent
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_TIMEOUT_MINUTES: 20
- GH_AW_VERSION: v0.84.0
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_AW: true
- GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
- GITHUB_WORKSPACE: ${{ github.workspace }}
- GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_AUTHOR_NAME: github-actions[bot]
- GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_COMMITTER_NAME: github-actions[bot]
- RUNNER_TEMP: ${{ runner.temp }}
- S2STOKENS: true
- TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- - name: Detect agent errors
- if: always()
- id: detect-agent-errors
- continue-on-error: true
- run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
- - name: Configure Git credentials
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_TOKEN: ${{ github.token }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Copy Copilot session state files to logs
- if: always()
- continue-on-error: true
- run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh"
- - name: Stop MCP Gateway
- if: always()
- continue-on-error: true
- env:
- MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
- MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
- GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
- run: |
- bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
- - name: Redact secrets in logs
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs');
- await main();
- env:
- GH_AW_SECRET_NAMES: 'GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
- SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
- SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
- SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Append agent step summary
- if: always()
- run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh"
- - name: Copy Safe Outputs
- if: always()
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- run: |
- mkdir -p /tmp/gh-aw
- cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
- - name: Ingest agent output
- id: collect_output
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dev.azure.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,learn.microsoft.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs');
- await main();
- - name: Parse agent logs for step summary
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
- GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs');
- await main();
- - name: Parse MCP Gateway logs for step summary
- if: always()
- id: parse-mcp-gateway
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs');
- await main();
- - name: Print firewall logs
- if: always()
- continue-on-error: true
- env:
- AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
- run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless
- - name: Parse token usage for step summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
- await main();
- - name: Print AWF reflect summary
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs');
- await main();
- - name: Write agent output placeholder if missing
- if: always()
- run: |
- if [ ! -f /tmp/gh-aw/agent_output.json ]; then
- echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
- fi
- - name: Upload agent artifacts
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: agent
- path: |
- /tmp/gh-aw/aw-prompts/prompt.txt
- /tmp/gh-aw/sandbox/agent/logs/
- /tmp/gh-aw/redacted-urls.log
- /tmp/gh-aw/mcp-logs/
- /tmp/gh-aw/agent_usage.json
- /tmp/gh-aw/agent-stdio.log
- /tmp/gh-aw/pre-agent-audit.txt
- /tmp/gh-aw/agent/
- /tmp/gh-aw/github_rate_limits.jsonl
- /tmp/gh-aw/safeoutputs.jsonl
- /tmp/gh-aw/agent_output.json
- /tmp/gh-aw/aw-*.patch
- /tmp/gh-aw/aw-*.bundle
- /tmp/gh-aw/awf-config.json
- /tmp/gh-aw/sandbox/firewall/logs/
- /tmp/gh-aw/sandbox/firewall/audit/
- /tmp/gh-aw/sandbox/firewall/awf-reflect.json
- if-no-files-found: ignore
-
- conclusion:
- needs:
- - activation
- - agent
- - detection
- - safe_outputs
- if: >
- always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
- needs.activation.outputs.oauth_token_check_failed == 'true' || needs.activation.outputs.stale_lock_file_failed == 'true')
- runs-on: ubuntu-slim
- permissions:
- contents: write
- issues: write
- pull-requests: write
- concurrency:
- group: "gh-aw-conclusion-recompile-safe-output-fixtures"
- cancel-in-progress: false
- queue: max
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
- noop_message: ${{ steps.noop.outputs.noop_message }}
- tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
- total_count: ${{ steps.missing_tool.outputs.total_count }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@f3ca20900e2363607992fb61b46fc687d4b56ba3 # v0.84.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/recompile-safe-output-fixtures.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.75"
- GH_AW_INFO_AWF_VERSION: "v0.27.42"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Download safe outputs items manifest
- id: download-safe-outputs-manifest
- if: always()
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: safe-outputs-items
- path: /tmp/gh-aw/
- - name: Collect usage artifact files
- if: always()
- continue-on-error: true
- run: |
- mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection
- echo "Usage artifact source file status:"
- for file in /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do
- [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file"
- done
- [ -f /tmp/gh-aw/aw_info.json ] && cp /tmp/gh-aw/aw_info.json /tmp/gh-aw/usage/aw_info.json || true
- [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true
- [ -f /tmp/gh-aw/agent_usage.json ] && cp /tmp/gh-aw/agent_usage.json /tmp/gh-aw/usage/agent_usage.json || true
- [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true
- [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true
- [ -f /tmp/gh-aw/evals/evals.jsonl ] && cp /tmp/gh-aw/evals/evals.jsonl /tmp/gh-aw/usage/evals.jsonl || true
- [ -f /tmp/gh-aw/github_rate_limits.jsonl ] && cp /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/usage/github_rate_limits.jsonl || true
- [ -s /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
- [ -s /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
- [ -s /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
- [ -s /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
- [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
- [ -s /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
- [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl
- [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl
- mkdir -p /tmp/gh-aw/usage/activity
- node "${RUNNER_TEMP}/gh-aw/actions/generate_usage_activity_summary.cjs"
- find /tmp/gh-aw/usage -type f -print | sort
- - name: Upload usage artifact
- if: always()
- continue-on-error: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: usage
- path: |
- /tmp/gh-aw/usage/aw_info.json
- /tmp/gh-aw/usage/aw-info.jsonl
- /tmp/gh-aw/usage/agent_usage.json
- /tmp/gh-aw/usage/agent_usage.jsonl
- /tmp/gh-aw/usage/detection_usage.jsonl
- /tmp/gh-aw/usage/evals.jsonl
- /tmp/gh-aw/usage/github_rate_limits.jsonl
- /tmp/gh-aw/usage/agent/token_usage.jsonl
- /tmp/gh-aw/usage/detection/token_usage.jsonl
- /tmp/gh-aw/usage/activity/summary.json
- if-no-files-found: ignore
- - name: Process no-op messages
- id: noop
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_NOOP_MAX: "1"
- GH_AW_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/recompile-safe-output-fixtures.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_NOOP_REPORT_AS_ISSUE: "true"
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_WORKFLOW_ID: "recompile-safe-output-fixtures"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
- await main();
- - name: Log detection run
- id: detection_runs
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/recompile-safe-output-fixtures.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
- GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_detection_runs.cjs');
- await main();
- - name: Record missing tool
- id: missing_tool
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/recompile-safe-output-fixtures.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
- await main();
- - name: Record incomplete
- id: report_incomplete
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
- GH_AW_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/recompile-safe-output-fixtures.md"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs');
- await main();
- - name: Handle agent failure
- id: handle_agent_failure
- if: always()
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/recompile-safe-output-fixtures.md"
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_WORKFLOW_ID: "recompile-safe-output-fixtures"
- GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
- GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
- GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
- GH_AW_MAX_AI_CREDITS: "-1"
- GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
- GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
- GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
- GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }}
- GH_AW_HTTP_400_RESPONSE_ERROR: ${{ needs.agent.outputs.http_400_response_error }}
- GH_AW_MISSING_MODEL_PRICING_ERROR: ${{ needs.agent.outputs.missing_model_pricing_error }}
- GH_AW_MISSING_MODEL_PRICING_MODEL_NAME: ${{ needs.agent.outputs.missing_model_pricing_model_name }}
- GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
- GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }}
- GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }}
- GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
- GH_AW_OAUTH_TOKEN_CHECK_FAILED: ${{ needs.activation.outputs.oauth_token_check_failed }}
- GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
- GH_AW_GROUP_REPORTS: "false"
- GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
- GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
- GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
- GH_AW_TIMEOUT_MINUTES: "20"
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
- await main();
-
- detection:
- needs:
- - activation
- - agent
- if: always() && needs.agent.result != 'skipped'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- copilot-requests: write
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- aic: ${{ steps.parse_detection_token_usage.outputs.aic }}
- detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
- detection_reason: ${{ steps.detection_conclusion.outputs.reason }}
- detection_success: ${{ steps.detection_conclusion.outputs.success }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@f3ca20900e2363607992fb61b46fc687d4b56ba3 # v0.84.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/recompile-safe-output-fixtures.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.75"
- GH_AW_INFO_AWF_VERSION: "v0.27.42"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Checkout repository for patch context
- if: needs.agent.outputs.has_patch == 'true'
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- # --- Threat Detection ---
- - name: Clean stale firewall files from agent artifact
- run: |
- rm -rf /tmp/gh-aw/sandbox/firewall/logs
- rm -rf /tmp/gh-aw/sandbox/firewall/audit
- - name: Download container images
- run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.42@sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b ghcr.io/github/gh-aw-firewall/api-proxy:0.27.42@sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607 ghcr.io/github/gh-aw-firewall/squid:0.27.42@sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0
- - name: Check if detection needed
- id: detection_guard
- if: always()
- env:
- OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }}
- HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
- run: |
- if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then
- echo "run_detection=true" >> "$GITHUB_OUTPUT"
- echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH"
- else
- echo "run_detection=false" >> "$GITHUB_OUTPUT"
- echo "Detection skipped: no agent outputs or patches to analyze"
- fi
- - name: Clear MCP Config for detection
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- run: |
- rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json"
- rm -f "$HOME/.copilot/mcp-config.json"
- rm -f "$GITHUB_WORKSPACE/.gemini/settings.json"
- - name: Prepare threat detection files
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- run: |
- mkdir -p /tmp/gh-aw/threat-detection/aw-prompts
- rm -f /tmp/gh-aw/agent_usage.json
- cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true
- if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then
- echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context."
- fi
- cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true
- for f in /tmp/gh-aw/aw-*.patch; do
- [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
- done
- for f in /tmp/gh-aw/aw-*.bundle; do
- [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
- done
- echo "Prepared threat detection files:"
- ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true
- - name: Setup threat detection
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- WORKFLOW_NAME: "Recompile safe-output fixtures"
- WORKFLOW_DESCRIPTION: "Recompiles every tests/safe-outputs/*.lock.yml fixture using the latest released ado-aw binary, then opens a single PR if anything changed."
- HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs');
- await main();
- - name: Ensure threat-detection directory and log
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- run: |
- mkdir -p /tmp/gh-aw/threat-detection
- touch /tmp/gh-aw/threat-detection/detection.log
- - name: Setup Node.js
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: '24'
- package-manager-cache: false
- - name: Install GitHub Copilot CLI
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.75
- env:
- GH_HOST: github.com
- - name: Install AWF binary
- run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.42
- - name: Execute GitHub Copilot CLI
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- continue-on-error: true
- id: detection_agentic_execution
- # Copilot CLI tool arguments (sorted):
- timeout-minutes: 20
- run: |
- set -o pipefail
- printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
- trap 'gh_aw_exit_code=$?; mkdir -p /tmp/gh-aw >/dev/null 2>&1 || true; printf "%s" "$gh_aw_exit_code" > /tmp/gh-aw/agent_execution_exit_code.txt || true; rm -f "$HOME/.copilot/settings.json"' EXIT
- mkdir -p "$HOME/.copilot"
- printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
- export XDG_CONFIG_HOME="$HOME"
- touch /tmp/gh-aw/agent-step-summary.md
- GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
- export GH_AW_NODE_BIN
- export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
- (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log)
- # shellcheck disable=SC2016
- printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.27.42/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","registry.npmjs.org","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true,"maxRuns":500,"maxCacheMisses":5,"models":{"agent":["sonnet-6x","gpt-5.4","gpt-5.5","gpt-5.6","gpt-5.3","gemini-pro","any"],"antigravity":["copilot/antigravity*","google/antigravity*","gemini/antigravity*"],"any":["copilot/*","anthropic/*","openai/*","google/*","gemini/*"],"auto":["copilot/auto","large"],"claude":["agent"],"codex":["agent"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex","kimi"],"computer-use":["copilot/*computer-use*","google/*computer-use*","gemini/*computer-use*","openai/*computer-use*"],"copilot":["agent"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"fable":["copilot/*fable*","anthropic/*fable*"],"gemini":["agent"],"gemini-3-flash":["copilot/gemini-3*flash*","google/gemini-3*flash*","gemini/gemini-3*flash*"],"gemini-3-pro":["copilot/gemini-3*pro*","google/gemini-3*pro*","google/nano-banana*","gemini/gemini-3*pro*"],"gemini-3.1-flash":["copilot/gemini-3.1*flash*","google/gemini-3.1*flash*","gemini/gemini-3.1*flash*"],"gemini-3.1-pro":["copilot/gemini-3.1*pro*","google/gemini-3.1*pro*","gemini/gemini-3.1*pro*"],"gemini-3.5-flash":["copilot/gemini-3.5*flash*","google/gemini-3.5*flash*","gemini/gemini-3.5*flash*"],"gemini-3.6-flash":["copilot/gemini-3.6*flash*","google/gemini-3.6*flash*","gemini/gemini-3.6*flash*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-omni":["copilot/gemini-omni*","google/gemini-omni*","gemini/gemini-omni*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"gpt-5.1":["copilot/gpt-5.1*","openai/gpt-5.1*"],"gpt-5.2":["copilot/gpt-5.2*","openai/gpt-5.2*"],"gpt-5.3":["copilot/gpt-5.3*","openai/gpt-5.3*"],"gpt-5.4":["copilot/gpt-5.4*","openai/gpt-5.4*"],"gpt-5.5":["copilot/gpt-5.5*","openai/gpt-5.5*"],"gpt-5.6":["copilot/gpt-5.6*","openai/gpt-5.6*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"image-generation":["copilot/gpt-image*","openai/gpt-image*","openai/chatgpt-image*","copilot/gemini-*image*","google/gemini-*image*","gemini/gemini-*image*","google/imagen*"],"kimi":["copilot/kimi*","openai/kimi*"],"kiwi":["copilot/kiwi*","openai/kiwi*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"lyria":["google/lyria*","gemini/lyria*","copilot/lyria*"],"mai-code":["copilot/MAI-Code*","copilot/mai-code*","openai/MAI-Code*"],"mai-code-1-flash-picker":["copilot/MAI-Code-1-Flash-picker*","copilot/mai-code-1-flash-picker*","openai/MAI-Code-1-Flash-picker*"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"nano-banana":["copilot/nano-banana*","google/nano-banana*","gemini/nano-banana*"],"opus":["copilot/*opus*","anthropic/*opus*"],"opusplan":["opus?effort=high"],"raptor-mini":["copilot/raptor*","openai/raptor*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"robotics":["copilot/*robotics*","google/*robotics*","gemini/*robotics*"],"small":["mini"],"small-agent":["haiku","gpt-5-mini","gemini-flash"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"sonnet-6x":["copilot/*sonnet-4.5*","copilot/*sonnet-4.6*","copilot/*sonnet-5*","copilot/*sonnet-4-5-*","anthropic/*sonnet-4-5-*","copilot/*sonnet-4-6*","anthropic/*sonnet-4-6*","anthropic/*sonnet-5*"],"summarization":["haiku","gpt-5-mini","gemini-flash-lite","mini"],"veo":["google/veo*","gemini/veo*"],"vision":["copilot/gemini-*image*","google/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.27.42,squid=sha256:42dfeb649c680a8558cd5423dbc530b653a69413e35ffbe5e71da5d48c94bdf0,agent=sha256:26a8af4e5566485b02f52af59ee03803ae798271a9619d4767e94d07806deb9b,agent-act=sha256:a14ad974484aa518aab83d40f3f141175dfd171d3745e01c092375b970f73a20,api-proxy=sha256:944f2686c9ab9bec338fd14b662461662f77cd12cd0ea8a3e7cb8c0987cd1607,cli-proxy=sha256:da006bf96d2d246dd269d57b233c1798d2ad63d6cd64ca02f7bf71045028781f"},"logging":{"proxyLogsDir":"/tmp/gh-aw/sandbox/firewall/logs","auditDir":"/tmp/gh-aw/sandbox/firewall/audit"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
- export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
- GH_AW_DOCKER_HOST=""
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- GH_AW_DOCKER_HOST="${DOCKER_HOST}"
- fi
- if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
- _GH_AW_CHROOT_JSON=$(jq -c --arg src "${RUNNER_TEMP}/gh-aw" --arg user "$(id -un)" --argjson uid "$(id -u)" --argjson gid "$(id -g)" --arg home "${RUNNER_TEMP}/gh-aw/home" '.chroot={"binariesSourcePath":$src,"identity":{"user":$user,"uid":$uid,"gid":$gid,"home":$home}}' "${RUNNER_TEMP}/gh-aw/awf-config.json") || { echo "chroot config patch failed" >&2; exit 1; }
- printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- printf '%s\n' "$_GH_AW_CHROOT_JSON" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
- fi
- GH_AW_TOOL_CACHE_MOUNT=""
- GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"
- if [ -d "$GH_AW_TOOL_CACHE" ]; then
- if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
- GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
- fi
- fi
- # shellcheck disable=SC1003,SC2016,SC2086
- awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --skip-pull \
- -- /bin/bash -c 'set +o histexpand; : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
- env:
- AWF_REFLECT_ENABLED: 1
- COPILOT_AGENT_RUNNER_TYPE: STANDALONE
- COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
- COPILOT_GITHUB_TOKEN: ${{ github.token }}
- COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'auto' }}
- GH_AW_LLM_PROVIDER: github
- GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
- GH_AW_PHASE: detection
- GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
- GH_AW_TIMEOUT_MINUTES: 20
- GH_AW_VERSION: v0.84.0
- GITHUB_API_URL: ${{ github.api_url }}
- GITHUB_AW: true
- GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
- GITHUB_HEAD_REF: ${{ github.head_ref }}
- GITHUB_REF_NAME: ${{ github.ref_name }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
- GITHUB_WORKSPACE: ${{ github.workspace }}
- GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_AUTHOR_NAME: github-actions[bot]
- GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
- GIT_COMMITTER_NAME: github-actions[bot]
- RUNNER_TEMP: ${{ runner.temp }}
- S2STOKENS: true
- TRACEPARENT: ${{ env.GITHUB_AW_OTEL_TRACE_ID != '' && env.GITHUB_AW_OTEL_PARENT_SPAN_ID != '' && format('00-{0}-{1}-01', env.GITHUB_AW_OTEL_TRACE_ID, env.GITHUB_AW_OTEL_PARENT_SPAN_ID) || '' }}
- - name: Parse threat detection token usage for step summary
- id: parse_detection_token_usage
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_TOKEN_USAGE_SUMMARY_TITLE: Threat Detection Token Usage
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
- await main();
- - name: Upload threat detection log
- if: always() && steps.detection_guard.outputs.run_detection == 'true'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: detection
- path: /tmp/gh-aw/threat-detection/detection.log
- if-no-files-found: ignore
- - name: Parse and conclude threat detection
- id: detection_conclusion
- if: always()
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
- DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }}
- GH_AW_DETECTION_CONTINUE_ON_ERROR: "true"
- with:
- script: |
- try {
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs');
- await main();
- } catch (loadErr) {
- const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false';
- const detectionExecutionFailed = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME === 'failure';
- const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr));
- core.error(msg);
- core.setOutput('reason', 'parse_error');
- if (continueOnError && !detectionExecutionFailed) {
- core.warning('\u26A0\uFE0F ' + msg);
- core.setOutput('conclusion', 'warning');
- core.setOutput('success', 'false');
- } else {
- core.setOutput('conclusion', 'failure');
- core.setOutput('success', 'false');
- core.setFailed(msg);
- }
- }
-
- pre_activation:
- runs-on: ubuntu-slim
- env:
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- outputs:
- activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
- matched_command: ''
- setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
- setup-span-id: ${{ steps.setup.outputs.span-id }}
- setup-trace-id: ${{ steps.setup.outputs.trace-id }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@f3ca20900e2363607992fb61b46fc687d4b56ba3 # v0.84.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/recompile-safe-output-fixtures.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.75"
- GH_AW_INFO_AWF_VERSION: "v0.27.42"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Check team membership for workflow
- id: check_membership
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
- GH_AW_ALLOWED_BOTS: "github-actions[bot]"
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
- await main();
-
- safe_outputs:
- needs:
- - activation
- - agent
- - detection
- if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
- runs-on: ubuntu-slim
- permissions:
- contents: write
- issues: write
- pull-requests: write
- timeout-minutes: 45
- env:
- GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AIC: ${{ needs.agent.outputs.aic }}
- GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
- GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/recompile-safe-output-fixtures"
- GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
- GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
- GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
- GH_AW_ENGINE_ID: "copilot"
- GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
- GH_AW_ENGINE_VERSION: "1.0.75"
- GH_AW_RUNTIME_FEATURES: ${{ vars.GH_AW_RUNTIME_FEATURES }}
- GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
- GH_AW_WORKFLOW_ID: "recompile-safe-output-fixtures"
- GH_AW_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/recompile-safe-output-fixtures.md"
- outputs:
- code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
- code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
- create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
- create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
- created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }}
- created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }}
- process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
- process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
- steps:
- - name: Setup Scripts
- id: setup
- uses: github/gh-aw-actions/setup@f3ca20900e2363607992fb61b46fc687d4b56ba3 # v0.84.0
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- job-name: ${{ github.job }}
- trace-id: ${{ needs.activation.outputs.setup-trace-id }}
- parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
- env:
- GH_AW_SETUP_WORKFLOW_NAME: "Recompile safe-output fixtures"
- GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/recompile-safe-output-fixtures.lock.yml@${{ github.ref }}
- GH_AW_INFO_VERSION: "1.0.75"
- GH_AW_INFO_AWF_VERSION: "v0.27.42"
- GH_AW_INFO_ENGINE_ID: "copilot"
- - name: Download agent output artifact
- id: download-agent-output
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Setup agent output environment variable
- id: setup-agent-output-env
- if: steps.download-agent-output.outcome == 'success'
- run: |
- mkdir -p /tmp/gh-aw/
- find "/tmp/gh-aw/" -type f -print
- echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- - name: Download patch artifact
- continue-on-error: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: agent
- path: /tmp/gh-aw/
- - name: Checkout repository
- if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request')
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: true
- token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- - name: Configure Git credentials
- if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request')
- env:
- GITHUB_REPOSITORY: ${{ github.repository }}
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
- - name: Configure GH_HOST for enterprise compatibility
- id: ghes-host-config
- shell: bash
- run: | # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
- # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
- # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
- GH_HOST="${GITHUB_SERVER_URL#https://}"
- GH_HOST="${GH_HOST#http://}"
- echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- - name: Process Safe Outputs
- id: process_safe_outputs
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- env:
- GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
- GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
- GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dev.azure.com,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,learn.microsoft.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,patchdiff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
- GITHUB_SERVER_URL: ${{ github.server_url }}
- GITHUB_API_URL: ${{ github.api_url }}
- GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"close_pull_request\":{\"max\":5,\"required_title_prefix\":\"chore(workflows): recompile safe-output fixtures\",\"target\":\"*\"},\"create_pull_request\":{\"allowed_files\":[\"tests/safe-outputs/*.lock.yml\",\"tests/safe-outputs/**/*.lock.yml\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":4096,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\",\"title_prefix\":\"chore(workflows): \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
- GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io, getOctokit);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/process_safe_outputs.cjs');
- await main();
- - name: Upload Safe Outputs Items
- if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: safe-outputs-items
- path: |
- /tmp/gh-aw/safe-output-items.jsonl
- /tmp/gh-aw/temporary-id-map.json
- /tmp/gh-aw/process-safe-outputs.stdout.log
- /tmp/gh-aw/process-safe-outputs.stderr.log
- if-no-files-found: ignore
diff --git a/.github/workflows/recompile-safe-output-fixtures.md b/.github/workflows/recompile-safe-output-fixtures.md
deleted file mode 100644
index c5940a93..00000000
--- a/.github/workflows/recompile-safe-output-fixtures.md
+++ /dev/null
@@ -1,261 +0,0 @@
----
-on:
- bots: ["github-actions[bot]"]
- release:
- types: [published]
- workflow_dispatch:
- inputs:
- version:
- description: "ado-aw release tag to recompile against (e.g. v0.16.0). Leave blank to use the latest published release."
- required: false
- type: string
-description: Recompiles every tests/safe-outputs/*.lock.yml fixture using the latest released ado-aw binary, then opens a single PR if anything changed.
-permissions:
- contents: read
- pull-requests: read
- issues: read
- copilot-requests: write
-tools:
- github:
- toolsets: [default]
- bash: ["*"]
-network:
- allowed: [defaults, github, dev.azure.com, learn.microsoft.com]
-safe-outputs:
- threat-detection:
- max-ai-credits: -1
- create-pull-request:
- title-prefix: "chore(workflows): "
- max: 1
- allowed-files:
- - "tests/safe-outputs/*.lock.yml"
- - "tests/safe-outputs/**/*.lock.yml"
- close-pull-request:
- required-title-prefix: "chore(workflows): recompile safe-output fixtures"
- target: "*"
- max: 5
-max-ai-credits: -1
-max-daily-ai-credits: -1
----
-
-# Recompile safe-output fixtures
-
-You are a deterministic release-driven recompiler for the **ado-aw** project. Every fixture under `tests/safe-outputs/` is a daily smoke pipeline whose `.lock.yml` is produced by running `ado-aw compile .md`. Each released version of ado-aw bumps the embedded `version` field in the `# ado-aw-metadata: { … }` JSON marker at the top of every lock file (and may change other compiled output). When that bump is not propagated, the daily smokes drift away from the version of ado-aw that real users run.
-
-Your goal each run is to land **at most one** focused PR that recompiles `tests/safe-outputs/*.lock.yml` against the **latest released** `ado-aw` binary. If nothing in the directory actually changes after recompilation, emit `noop` and exit.
-
-> **Note on triggers.** This workflow is invoked by the `release` event when a new ado-aw release is published, or manually via `workflow_dispatch`. Release-please publishes the GitHub Release first; the `build` job inside the `Release` workflow then uploads the platform binaries and `checksums.txt`. By the time the `release` event fires both should be present, but Step 2 below adds a bounded retry so a slightly delayed asset upload does not cause a hard failure.
-
-## Step 1 — Resolve the target ado-aw version
-
-Determine the version tag to compile against, in this priority order:
-
-1. If `workflow_dispatch` was used and the `version` input is non-empty, use it.
-2. Otherwise, query the GitHub Releases API for the latest published release of `githubnext/ado-aw`:
-
- ```bash
- gh release view --repo githubnext/ado-aw --json tagName --jq '.tagName'
- ```
-
-Normalize the result by stripping any leading `v`, then re-add it once so every downstream step uses the canonical `vX.Y.Z` form:
-
-```bash
-RAW=""
-BARE="${RAW#v}"
-TAG="v${BARE}"
-echo "Resolved ado-aw release: tag=$TAG bare=$BARE"
-```
-
-`TAG` is used in URLs and PR titles. `BARE` is used wherever a leading `v` would be wrong (e.g. comparing against the `version` field inside the lock-file metadata marker, which is stored without a `v`).
-
-If the resolved tag is malformed (does not match `v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?`), abort with `missing-data` describing the bad input. Never proceed with an empty or unverified tag.
-
-## Step 2 — Download and verify the released `ado-aw-linux-x64` binary
-
-Release-please publishes the GitHub Release first; the `build` job inside the `Release` workflow then uploads the platform binaries and `checksums.txt`. By the time the `release` event fires both should be present, but add a bounded retry so a slightly delayed asset upload does not cause a hard failure.
-
-```bash
-set -euo pipefail
-mkdir -p /tmp/gh-aw/agent/ado-aw-bin
-BIN_URL="https://github.com/githubnext/ado-aw/releases/download/${TAG}/ado-aw-linux-x64"
-SUM_URL="https://github.com/githubnext/ado-aw/releases/download/${TAG}/checksums.txt"
-
-# Up to ~6 minutes of bounded retry (12 attempts × 30s) covers asset-upload latency.
-for attempt in $(seq 1 12); do
- if curl -fsSL -o /tmp/gh-aw/agent/ado-aw-bin/ado-aw "$BIN_URL" \
- && curl -fsSL -o /tmp/gh-aw/agent/ado-aw-bin/checksums.txt "$SUM_URL"; then
- break
- fi
- echo "attempt=$attempt: release assets not yet present, sleeping 30s…"
- sleep 30
-done
-
-# After the loop, both files must exist; otherwise abort hard.
-test -s /tmp/gh-aw/agent/ado-aw-bin/ado-aw
-test -s /tmp/gh-aw/agent/ado-aw-bin/checksums.txt
-```
-
-Verify the SHA256 strictly — parse the exact filename column, not a loose `grep`:
-
-```bash
-cd /tmp/gh-aw/agent/ado-aw-bin
-EXPECTED="$(awk '$2 == "ado-aw-linux-x64" || $2 == "*ado-aw-linux-x64" { print $1 }' checksums.txt | head -1)"
-test -n "$EXPECTED" || { echo "no checksum entry for ado-aw-linux-x64"; exit 1; }
-ACTUAL="$(sha256sum ado-aw | awk '{print $1}')"
-[ "$EXPECTED" = "$ACTUAL" ] || { echo "checksum mismatch: expected=$EXPECTED actual=$ACTUAL"; exit 1; }
-chmod +x ado-aw
-./ado-aw --version
-```
-
-If the version printed by `./ado-aw --version` does not contain `BARE`, abort with `missing-data` describing the mismatch — you have downloaded the wrong asset.
-
-## Step 2.5 — Pre-flight integrity check on the existing lock files
-
-Before recompiling, run `ado-aw check` against every existing `tests/safe-outputs/*.lock.yml` using the **released** binary. The `check` subcommand recompiles each pipeline from its source `.md` and compares against the committed lock file; a non-zero exit means the on-disk lock file does **not** match what the released compiler would produce — i.e. it drifted (for example because someone recompiled with a dev build off `main` and merged that, or because a release shipped output changes that were never propagated). This is a primary signal that we need to recompile, independent of whether the source `.md` changed.
-
-```bash
-set -euo pipefail
-cd "$GITHUB_WORKSPACE"
-mkdir -p /tmp/gh-aw/agent
-: > /tmp/gh-aw/agent/integrity-failures.txt
-INTEGRITY_FAIL_COUNT=0
-for lock in tests/safe-outputs/*.lock.yml; do
- if /tmp/gh-aw/agent/ado-aw-bin/ado-aw check "$lock" \
- > "/tmp/gh-aw/agent/check-$(basename "$lock").log" 2>&1; then
- echo "PASS $lock"
- else
- echo "FAIL $lock (exit $?)"
- echo "$lock" >> /tmp/gh-aw/agent/integrity-failures.txt
- INTEGRITY_FAIL_COUNT=$((INTEGRITY_FAIL_COUNT + 1))
- fi
-done
-echo "integrity_failures=$INTEGRITY_FAIL_COUNT"
-```
-
-Record the failure count and the failing-file list — both go in the PR body (Step 6) when a PR is opened. Do **not** abort on integrity failure here; this step is diagnostic only. Recompilation in Step 3 is what fixes the drift. If a single per-file check log shows an error other than a content mismatch (for example a missing source file, a codemod-required source, or an internal compiler error), include the relevant log excerpt in the PR body or — if recompile in Step 3 cannot succeed either — fall back to `report-incomplete` from Step 3.
-
-## Step 3 — Recompile every fixture in `tests/safe-outputs/`
-
-`ado-aw compile` accepts a single `.md` path or no arguments (cwd autodiscovery). It does **not** accept a directory argument — passing one silently produces `0 compiled, N skipped`, which is exactly the failure mode that took down [run 27020309715](https://github.com/githubnext/ado-aw/actions/runs/27020309715) and is tracked in [issue #867](https://github.com/githubnext/ado-aw/issues/867). Loop per-file from the repo root instead, and pass `--force` to bypass the GitHub-remote guard (required when running inside `githubnext/ado-aw` itself):
-
-```bash
-set -euo pipefail
-cd "$GITHUB_WORKSPACE"
-: > /tmp/gh-aw/agent/recompile.log
-for md in tests/safe-outputs/*.md; do
- echo ">>> compiling $md" | tee -a /tmp/gh-aw/agent/recompile.log
- /tmp/gh-aw/agent/ado-aw-bin/ado-aw compile --force "$md" 2>&1 | tee -a /tmp/gh-aw/agent/recompile.log
-done
-```
-
-If any per-file compile exits non-zero, the script aborts via `set -euo pipefail` and partial output is left on disk. Do **not** open a PR in that state — emit `report-incomplete` with the last ~80 lines of `/tmp/gh-aw/agent/recompile.log` so a maintainer can investigate. Stop.
-
-## Step 3.5 — Post-compile sanity check
-
-After recompiling, re-run `ado-aw check` against every lock file using the same released binary. Every check **must** now pass — if any still fails, our just-produced output disagrees with itself, which means the compile silently mis-handled something. That is a hard failure:
-
-```bash
-set -euo pipefail
-cd "$GITHUB_WORKSPACE"
-for lock in tests/safe-outputs/*.lock.yml; do
- /tmp/gh-aw/agent/ado-aw-bin/ado-aw check "$lock" \
- > "/tmp/gh-aw/agent/postcheck-$(basename "$lock").log" 2>&1 \
- || { echo "post-compile integrity STILL failing for $lock"; cat "/tmp/gh-aw/agent/postcheck-$(basename "$lock").log"; exit 1; }
-done
-echo "all lock files pass integrity check against ado-aw ${TAG}"
-```
-
-If this step fails, emit `report-incomplete` with the offending file name and the tail of its postcheck log; do **not** open a PR with broken integrity.
-
-## Step 4 — Detect actual changes
-
-Use `git status --porcelain` scoped to the fixture directory:
-
-```bash
-git status --porcelain -- tests/safe-outputs/ > /tmp/gh-aw/agent/recompile-status.txt
-cat /tmp/gh-aw/agent/recompile-status.txt
-```
-
-If `/tmp/gh-aw/agent/recompile-status.txt` is empty **and** `INTEGRITY_FAIL_COUNT` from Step 2.5 was `0`, the fixtures already match the released version — emit `noop` with the message `"tests/safe-outputs/ already compiled against ado-aw ${TAG} and all integrity checks pass"` and stop. Do **not** open an empty PR.
-
-If `/tmp/gh-aw/agent/recompile-status.txt` is empty **but** `INTEGRITY_FAIL_COUNT > 0`, this is contradictory — recompile produced no diff yet `check` reported drift. That should not happen in practice (a passing `check` and a no-op `compile` against the same source and binary must agree). Emit `report-incomplete` with the contents of `/tmp/gh-aw/agent/integrity-failures.txt` and the relevant `check-*.log` files so a maintainer can investigate. Stop.
-
-If non-empty, inspect the diff briefly to make sure only `.lock.yml` files under `tests/safe-outputs/` changed:
-
-```bash
-git diff --name-only -- tests/safe-outputs/ | sort
-```
-
-If any path outside `tests/safe-outputs/` appears, or if any non-`.lock.yml` file appears, abort with `report-incomplete` — that is unexpected output from the compiler and a maintainer should look. The `allowed-files` glob in the front matter is a backstop, not a license to broaden scope.
-
-## Step 5 — Determine the `from → to` version range for the PR body
-
-Pick any one of the recompiled lock files (for example `tests/safe-outputs/noop.lock.yml`) and read the `# ado-aw-metadata: { … }` line at the top — both the new working-tree copy and the old `HEAD` copy:
-
-```bash
-SAMPLE="tests/safe-outputs/noop.lock.yml"
-NEW_VER="$(head -n1 "$SAMPLE" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')"
-OLD_VER="$(git show HEAD:"$SAMPLE" 2>/dev/null | head -n1 | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')"
-echo "from=${OLD_VER:-unknown} to=${NEW_VER}"
-```
-
-`NEW_VER` should equal `BARE`; if not, abort with `missing-data` — the compiler did not stamp the version you expected.
-
-## Step 6 — Open the PR
-
-The `safe-outputs.create-pull-request.title-prefix` is configured to `chore(workflows): `, so gh-aw will prepend it automatically. Provide the title **without** that prefix.
-
-- **Title (provide without prefix)**: `recompile safe-output fixtures with ado-aw ${TAG}`
- - Published title: `chore(workflows): recompile safe-output fixtures with ado-aw ${TAG}`
-- **Branch base**: `main`
-- **Body**:
-
- ```markdown
- ## Recompile `tests/safe-outputs/` against ado-aw `${TAG}`
-
- Bumps the `version` field in every `tests/safe-outputs/*.lock.yml` metadata marker from `${OLD_VER}` to `${NEW_VER}`, picking up any compile-output changes shipped in [`ado-aw ${TAG}`](https://github.com/githubnext/ado-aw/releases/tag/${TAG}).
-
- ### Pre-flight integrity check
-
- Before recompiling, `ado-aw check` was run against every existing lock file using the released `${TAG}` binary:
-
- - **Integrity failures**: `${INTEGRITY_FAIL_COUNT}` of N files
-
- 0, include a fenced block listing the contents of `/tmp/gh-aw/agent/integrity-failures.txt`>
-
- ### Files updated
-
-
-
- ### How this was produced
-
- - Downloaded `ado-aw-linux-x64` from the `${TAG}` release and verified its SHA256 against `checksums.txt`.
- - Ran `ado-aw check tests/safe-outputs/*.lock.yml` against the released binary to detect drift (see counts above).
- - Ran `ado-aw compile tests/safe-outputs/` from the repo root.
- - Re-ran `ado-aw check` against every regenerated lock file; all passed.
- - The `allowed-files` glob in this workflow restricts the diff to `tests/safe-outputs/**/*.lock.yml`.
-
- ### Reviewer checklist
-
- - [ ] Diff is limited to metadata markers (and any genuinely new compile output for `${TAG}`).
- - [ ] No source `.md` fixture changes leaked in.
- - [ ] Daily smoke pipelines in the AgentPlayground sandbox stay green after merge.
-
- ---
- *This PR was opened automatically by the `recompile-safe-output-fixtures` workflow.*
- ```
-
-The `safe-outputs.close-pull-request` configuration on this workflow targets any open PR whose title starts with `chore(workflows): recompile safe-output fixtures`. After opening the new PR, emit one `close-pull-request` safe output per previously-open recompile PR (excluding the one you just opened) so superseded version bumps do not pile up. Each closure should include a short comment of the form `Superseded by the recompile PR for ado-aw ${TAG}.`. Do not close any PR whose title does not start with that exact prefix.
-
-## When NOT to open a PR
-
-- The resolved tag is missing or malformed (Step 1) — emit `missing-data`.
-- Release assets never appear within the bounded retry window (Step 2) — emit `report-incomplete`.
-- `ado-aw --version` does not contain `BARE` (Step 2) — emit `missing-data`.
-- `ado-aw compile` fails (Step 3) — emit `report-incomplete`.
-- Post-compile `ado-aw check` still fails for any lock file (Step 3.5) — emit `report-incomplete`.
-- `tests/safe-outputs/` is already at `BARE` **and** pre-flight integrity reported zero failures (Step 4) — emit `noop`.
-- Recompile produced no diff but pre-flight integrity reported failures (Step 4) — emit `report-incomplete`.
-- Compile output touched paths outside `tests/safe-outputs/*.lock.yml` (Step 4) — emit `report-incomplete`.
-
-Keep the PR small, mechanical, and reviewable. One release, one PR.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c4183e26..1bfc7b5c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -221,32 +221,3 @@ jobs:
TAG="${{ needs.release-please.outputs.tag_name || github.event.inputs.tag_name }}"
gh release upload "$TAG" checksums.txt --clobber --repo "${{ github.repository }}"
- trigger-recompile-safe-output-fixtures:
- name: Trigger safe-output fixture recompile
- needs: [release-please, checksums]
- # Run only once all release assets (binaries + checksums.txt) are uploaded.
- # Releases published via release-please do NOT fire the `release: published`
- # event on other workflows (GitHub suppresses this to prevent recursive
- # triggers), so we explicitly dispatch the recompile workflow here. The
- # default GITHUB_TOKEN has the `actions:write` scope needed to run
- # `gh workflow run`; the dispatched workflow uses its own secrets for any
- # downstream PR creation.
- if: >-
- always() &&
- (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') &&
- needs.checksums.result == 'success'
- runs-on: ubuntu-22.04
- permissions:
- actions: write
- steps:
- - name: Dispatch recompile-safe-output-fixtures
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -euo pipefail
- TAG="${{ needs.release-please.outputs.tag_name || github.event.inputs.tag_name }}"
- echo "Dispatching recompile-safe-output-fixtures for $TAG"
- gh workflow run recompile-safe-output-fixtures.lock.yml \
- --repo "${{ github.repository }}" \
- --ref main \
- -f "version=$TAG"
diff --git a/.github/workflows/review-compiler-contract.md b/.github/workflows/review-compiler-contract.md
index bf2e8d95..8b6ca25f 100644
--- a/.github/workflows/review-compiler-contract.md
+++ b/.github/workflows/review-compiler-contract.md
@@ -110,12 +110,17 @@ so frame it as "CI will fail" rather than as a silent risk.
If any `.github/workflows/*.md` changed without its `.lock.yml`, the workflow
will not run as written. Fix: `gh aw compile`.
-### Release-owned fixtures
+### Markdown-only smoke sources
-`tests/safe-outputs/*.lock.yml` are the **latest released** customer contract.
-Their runtime integrity step downloads the released compiler, so regenerating
-them from an unreleased checkout produces drift even when Cargo reports the same
-version. If this PR modifies them outside the release workflow, flag it.
+`tests/safe-outputs/` holds smoke **sources only** — no `*.lock.yml` files are
+committed there, and both smoke lanes recompile each markdown source at run
+time. If this PR adds a committed lock file under `tests/safe-outputs/`, that is
+a finding: it reintroduces the drift the lane model removed.
+
+Adding a smoke should be a markdown source plus one entry in
+`tests/smoke/cases.json`. A PR that instead registers a new ADO definition per
+test case, or adds a per-case `*_DEFINITION_ID` orchestrator variable, is
+working against the design — flag it.
## Step 3 — Schema and registry contracts
diff --git a/.github/workflows/review-tests.md b/.github/workflows/review-tests.md
index 075564f4..9d8c2bc1 100644
--- a/.github/workflows/review-tests.md
+++ b/.github/workflows/review-tests.md
@@ -75,9 +75,12 @@ Two ado-aw-specific rules worth knowing:
- **Any new `bash:` step in generated pipeline YAML must be covered by
`tests/bash_lint_tests.rs`.** ADO's "fail on last command" default lets silent
failures through, which is exactly what that test exists to catch.
-- **`tests/safe-outputs/*.lock.yml` are release-owned.** They are the latest
- released customer contract and must not be regenerated from a development
- checkout. If this PR regenerates them, that is a finding.
+- **`tests/safe-outputs/` is markdown-only.** Smoke sources are recompiled at
+ run time by both smoke lanes; no `*.lock.yml` is committed there. A PR that
+ adds one has reintroduced lock drift — that is a finding.
+- **New smokes should cost two files.** A markdown source plus one entry in
+ `tests/smoke/cases.json`. A PR that registers a per-case ADO definition or
+ adds a per-case `*_DEFINITION_ID` variable is working against the lane model.
## Step 1 — Load the pre-fetched data
diff --git a/.github/workflows/stale-bot-issue-janitor.md b/.github/workflows/stale-bot-issue-janitor.md
index 8c1e612a..eac85d26 100644
--- a/.github/workflows/stale-bot-issue-janitor.md
+++ b/.github/workflows/stale-bot-issue-janitor.md
@@ -104,10 +104,16 @@ Do **not** touch `[aw] …` issues that are neither `… failed` nor
`… hit AI credits rate limit` unless they are exact-title duplicates of each
other (≥2 with an identical title) — and even then never the protect-list ones.
-## Family B — superseded recompile-fixture chore issues
+## Family B — superseded recompile-fixture chore issues (historical backlog)
-The `recompile-safe-output-fixtures` workflow sometimes files an issue (a
-PR-creation fallback) titled
+The `recompile-safe-output-fixtures` workflow **has been retired**: smoke
+sources are now recompiled at run time by the smoke lanes and no `*.lock.yml`
+is committed under `tests/safe-outputs/`, so nothing files these any more. This
+family therefore only drains an existing backlog — expect it to find nothing on
+most runs.
+
+While it existed, that workflow sometimes filed an issue (a PR-creation
+fallback) titled
`chore(workflows): recompile safe-output fixtures with ado-aw v`, one per
ado-aw release. Only the **newest ado-aw version** is relevant; older ones are
superseded.
diff --git a/AGENTS.md b/AGENTS.md
index 6a6bc6ce..dca2accb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -285,7 +285,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output.
│ ├── approval-summary/ # Safe-outputs summary renderer (bundled to approval-summary.js; end-of-Agent-job summary tab)
│ ├── github-app-token/ # GitHub App token minter (bundled to github-app-token.js; mints installation token in Agent + Detection when engine.github-app-token is set)
│ ├── executor-e2e/ # Stage 3 safe-output E2E test harness (not a bundle; runs deterministic scenarios against a real ADO project and files a GitHub issue on failure)
-│ ├── compiler-smoke-e2e/ # Deterministic compiler-candidate smoke E2E orchestrator (not a bundle): stages a compiler candidate, pushes to a short-lived `ado-aw-mirror` branch, queues the four FIXED "candidate lane" pipeline definitions, and asserts they go green. Consumes fixtures from `tests/compiler-smoke-e2e/`; built to `test-bin/` by `build:compiler-smoke-e2e`, listed in `NON_BUNDLE_DIRS`.
+│ ├── compiler-smoke-e2e/ # Smoke E2E orchestrator (not a bundle): stages each case in `tests/smoke/cases.json` to the fixed `.smoke/pipeline.yml` path on its own per-case `ado-aw-mirror` ref, queues it against its credential *lane* definition, and asserts they go green. Two modes via `SMOKE_COMPILER_SOURCE`: `candidate` (compiler built from this commit, pinned pipeline-artifact) and `released` (latest release asset, release URLs required). Built to `test-bin/` by `build:compiler-smoke-e2e`, listed in `NON_BUNDLE_DIRS`.
│ ├── prepare-pr-base/ # create-pull-request preparer (bundled to prepare-pr-base.js): Agent mode uses ADO diff metadata + bounded dual-ref fallback to make the merge-base reachable; SafeOutputs mode fetches only the target worktree tip
│ ├── trigger-e2e/ # Test-only gate-spec / trigger-evaluation harness (not a bundle): mirrors Rust `Fact::ALL` in `gate-spec.ts`; `fact-catalog.gen.json` is generated by `export-fact-catalog` and drift-guarded by CI
│ └── shared/ # Shared modules across bundles (auth, ado-client, env-facts, types.gen.ts)
@@ -530,16 +530,25 @@ anything it flags. If a finding is genuinely intentional, add a
`# shellcheck disable=SCxxxx` comment immediately above the offending line in
the bash body — shellcheck honours the directive and it's inert at runtime.
-### Release-owned smoke lock files
-
-`tests/safe-outputs/*.lock.yml` are the latest-release customer contract. Do
-not regenerate them with `cargo run -- compile` from an unreleased checkout:
-their runtime integrity step downloads the released compiler, so development
-output can drift even while Cargo still reports the same semver. Compiler PRs
-and nightly `main` are exercised by `tests/compiler-smoke-e2e/`, which
-recompiles four selected workflows in a temporary worktree and stages them on an
-ephemeral `ado-aw-mirror` ref. The release workflow updates the checked-in
-locks only after matching release assets exist.
+### Markdown-only smoke suite
+
+`tests/safe-outputs/` holds smoke *sources* only — there are no committed
+`*.lock.yml` files and no ADO definitions registered against that directory.
+Both smoke lanes recompile each markdown source at run time, so nothing can
+drift between a checked-in lock and the compiler.
+
+`tests/smoke/` owns the machinery. Every case is staged to one fixed path
+(`.smoke/pipeline.yml`) on its own per-case mirror ref
+(`refs/heads/ado-aw-smoke-candidate//`) and queued against a
+*lane* definition, where a lane is a credential boundary (`agentic`, `debug`,
+`infra`) rather than a test case. Two orchestrators share the machinery:
+candidate mode (PR + nightly) compiles with the binary built from the checked-out
+commit; released mode (scheduled) compiles with the latest released binary and
+requires release URLs to survive into the output, which is what exercises
+release packaging.
+
+**Adding a smoke is a markdown file plus one entry in `tests/smoke/cases.json`
+— no ADO registration.** See `tests/smoke/README.md`.
## Common Tasks
diff --git a/SMOKE-REDESIGN-PLAN.md b/SMOKE-REDESIGN-PLAN.md
new file mode 100644
index 00000000..d3682ae9
--- /dev/null
+++ b/SMOKE-REDESIGN-PLAN.md
@@ -0,0 +1,472 @@
+# Plan: markdown-only, lane-based smoke suite
+
+> **Status.** Code, tests and docs are complete and verified locally
+> (`cargo test`, `npm run typecheck`, `npx vitest run`). What remains is the
+> ADO-side work that cannot be done from a checkout: registering the `agentic`
+> lane definition, the released orchestrator and the queue target, then a live
+> validation run and retiring the ten old definitions. See **Remaining work**.
+>
+> **Location.** Committed to the repository root as `SMOKE-REDESIGN-PLAN.md` so
+> it can be reviewed alongside the change. Delete it once the cutover completes
+> and the content has landed in `tests/smoke/README.md`.
+
+## Remaining work (ADO-side, cannot be done from a checkout)
+
+The `agentic` lane is **fully provisioned and verified live** — base ref, lane
+definition `2567`, secrets, service connections, agent-pool authorization, and
+a confirming run against the inert base ref. See the ✅ marks in
+[`tests/smoke/REGISTERED.md`](tests/smoke/REGISTERED.md). What is left:
+
+1. At merge: repoint definition `2559` at
+ `tests/smoke/azure-pipelines-candidate.yml` (its current path is deleted by
+ this change) and drop its six dead `COMPILER_SMOKE_*_DEFINITION_ID`
+ variables; register the released orchestrator and the executor-e2e queue
+ target; and set `E2E_QUEUE_PIPELINE_ID` off the retiring `2547`.
+ Authorize the agent pool on every new definition.
+2. Manually run both orchestrators and check the live assertions in
+ `tests/smoke/README.md`.
+3. Only once green: delete definitions `2545`–`2549`, `2554`–`2558`,
+ `2564`–`2565`; drop the legacy lock paths from the base ref; and remove
+ `2545`–`2549` from `tests/smoke/trigger-policy.json` in the same commit —
+ the policy audit fetches every listed id and a 404 aborts the run.
+
+## Problem
+
+Every new smoke costs a full Azure DevOps **definition registration**, because
+both smoke lanes map one test case to one definition. There are ten such
+definitions today (five release-backed, five candidate), plus five committed
+lock files that must be kept in sync by a dedicated agentic workflow.
+
+Per new smoke, today:
+
+| Cost | Where |
+| --- | --- |
+| Register definition, default branch = inert base ref | manual REST/UI |
+| Provision secret `GITHUB_TOKEN` (ADO definition clone never copies secrets) | manual |
+| Authorize service connections on the new definition | manual |
+| Apply + audit 6 fork-hardening flags; add to `trigger-policy.json` | manual |
+| New `COMPILER_SMOKE_*_DEFINITION_ID` variable on orchestrator `2559` | manual |
+| Commit an inert placeholder at the new lock path on the base ref | git |
+| Widen `FixtureName` union, `DEFINITION_ID_ENV_BY_FIXTURE`, `fixturePaths()` | code |
+| Commit a lock file, kept fresh by `recompile-safe-output-fixtures` | bot PR |
+| Write the markdown | code |
+
+Everything except the last row is attached to *the definition* or to *the
+committed lock*, not to the test.
+
+## Approach
+
+An ADO definition binds `(repo, yamlFilename)`; the **ref is supplied per
+queue**. So if every case compiles to the same path, the branch selects which
+pipeline runs. Invert the mapping:
+
+> **The ref carries the test case. The definition carries only the credentials.
+> The markdown is the only committed artefact.**
+
+Three ideas compose:
+
+1. **Fixed YAML path** `.smoke/pipeline.yml`, one **ref per case per run**
+ (`refs/heads/ado-aw-smoke-candidate//`), each a single
+ commit parented on `BUILD_SOURCEVERSION` — siblings, so one bulk object push
+ plus N tiny deltas.
+2. **Lane definitions** — one per credential class, queued N times.
+3. **Compiler-source modes** — the same machinery runs against either a
+ candidate compiler (built from the PR/nightly commit) or the **latest
+ released** compiler, so no committed lock is needed to exercise the release
+ path.
+
+Adding a smoke afterwards = **one markdown file + one manifest entry**.
+
+### Before / after
+
+```
+BEFORE 10 definitions + 5 committed locks
+ release lane (GitHub-backed, scheduled) candidate lane (mirror-backed)
+ 2545 canary <- canary.lock.yml 2554 canary
+ 2546 azure-cli <- azure-cli.lock.yml 2555 azure-cli
+ 2547 noop-target <- noop-target.lock.yml 2556 noop-target
+ 2548 janitor <- janitor.lock.yml 2558 smoke-failure-reporter
+ 2549 reporter <- reporter.lock.yml 2564 custom-safe-output
+
+AFTER 3 lane definitions + 1 queue target, zero committed locks
+ lane agentic <- .smoke/pipeline.yml <- refs ...//{canary,azure-cli,
+ noop-target,custom-safe-output,
+ multi-repo,janitor}
+ lane infra <- .smoke/pipeline.yml <- (ready for AWF / ado-proxy)
+ queue-target <- static YAML, permanent, not a smoke (executor-e2e dependency)
+
+ driven by two orchestrators over one shared steps template:
+ candidate mode - PR (comment-gated) + nightly - builds the compiler
+ released mode - scheduled daily - downloads latest release
+```
+
+### Confirmed decisions
+
+1. **Two lanes** — `agentic` (every current case; holds `GITHUB_TOKEN` and the
+ `agent-playground-*` service connections), `infra` (no credentials at all;
+ reserved for AWF and ado-proxy).
+
+ Earlier revisions split `smoke-failure-reporter` into its own `debug` lane
+ for its GitHub Issues PAT. That case has since been removed entirely — it
+ resolved its targets by ADO definition *name*, which the lane model
+ abolishes for cases, and two of the three names it watched are deleted at
+ cutover. Its intent (turn a failed scheduled run into a GitHub issue) is
+ tracked as a follow-up and belongs in the orchestrator as a deterministic
+ step, not in an agent holding a PAT.
+2. **Big-bang cutover** — all cases move in one PR. Mitigated by a manual
+ pre-merge live run in both modes, and by *disabling* rather than deleting
+ old definitions for one release cycle.
+3. **Infra lane infrastructure only** — build `kind: raw` support and register
+ the `infra` lane, but ship no AWF/proxy cases here.
+4. **Eliminate committed locks entirely** — delete all five
+ `tests/safe-outputs/*.lock.yml`, retire definitions 2545–2549, and recompile
+ from markdown at run time in both modes. Coverage consequences are accepted
+ and enumerated below.
+
+## Design
+
+### Compiler-source modes
+
+One variable, `COMPILER_SMOKE_COMPILER_SOURCE` ∈ `candidate | released`, drives
+every mode-dependent behaviour:
+
+| | `candidate` | `released` |
+| --- | --- | --- |
+| Compiler binary | built from `BUILD_SOURCEVERSION`, published as `ado-aw-candidate` | latest GitHub Release asset, downloaded by the orchestrator |
+| `supply-chain` transform | inject `pipeline-artifact` pinned to this run | **none** — compiled output keeps its release-URL integrity step |
+| Release-URL assertion | `assertNoForbiddenReleaseUrls` (must be absent) | **inverted**: release URLs must be **present** |
+| `assertPipelineArtifactValues` | required | skipped |
+| Rust build in orchestrator | yes | no (download only) |
+| Trigger | PR (comment-gated) + nightly `main` | scheduled daily |
+
+Released mode preserves the release-packaging signal that the committed locks
+provided: the orchestrator downloads a released asset to compile with, and every
+child then downloads released assets again via its own integrity step. A broken
+or missing release asset fails the run in both places.
+
+### Case manifest — `tests/smoke/cases.json`
+
+```jsonc
+{
+ "schema": "ado-aw/smoke-cases/1",
+ "yamlPath": ".smoke/pipeline.yml",
+ "lanes": {
+ "agentic": { "definitionIdEnv": "SMOKE_LANE_AGENTIC_DEFINITION_ID" },
+ "infra": { "definitionIdEnv": "SMOKE_LANE_INFRA_DEFINITION_ID" }
+ },
+ "cases": [
+ { "id": "canary", "lane": "agentic", "kind": "compiled",
+ "modes": ["candidate", "released"],
+ "source": "tests/safe-outputs/canary.md" },
+ { "id": "azure-cli", "lane": "agentic", "kind": "compiled",
+ "modes": ["candidate", "released"],
+ "source": "tests/safe-outputs/azure-cli.md",
+ "assertions": {
+ "agentCommand": {
+ "required": ["shell(az", "shell(head"],
+ "forbidden": ["--allow-all-tools", "--allow-all-paths"]
+ }
+ } },
+ { "id": "noop-target", "lane": "agentic", "kind": "compiled",
+ "modes": ["candidate", "released"],
+ "source": "tests/safe-outputs/noop-target.md" },
+ { "id": "custom-safe-output", "lane": "agentic", "kind": "compiled",
+ "modes": ["candidate"],
+ "source": "tests/smoke/custom-safe-output.md",
+ "assertions": { "requiredBuildTags": ["ado-aw-custom-job-{buildId}"] } },
+ { "id": "janitor", "lane": "agentic", "kind": "compiled",
+ "modes": ["released"],
+ "source": "tests/safe-outputs/janitor.md" }
+ ]
+}
+```
+
+`modes` replaces today's implicit "the candidate lane compiles four of the five"
+rule with an explicit, reviewable declaration.
+
+Validation is strict / fail-closed:
+
+- `id` matches `^[a-z0-9][a-z0-9-]{0,48}$` — **security-relevant**, the id is
+ interpolated into a git ref name.
+- `id` unique; `lane` must exist; `modes` non-empty and drawn from the known set.
+- `source` repo-relative, normalised, no `..`, must exist in the worktree.
+- `kind: compiled` sources end `.md`; `kind: raw` sources end `.yml`/`.yaml`.
+- `{buildId}` is the only supported tag placeholder.
+- Lane definition ids come from env: required positive integers, distinct.
+
+Read **from the detached worktree** (the exact `BUILD_SOURCEVERSION` tree), so
+`loadConfig()` splits into `loadConfig()` (env only) and
+`loadCases(worktreeDir, env, mode)` called after `createDetachedWorktree`.
+
+### Staging loop
+
+```
+for case of cases matching mode: # sequential, deterministic order
+ kind=compiled:
+ candidate mode: inject supply-chain.pipeline-artifact
+ both modes: strip the entire `on:` block
+ `ado-aw compile --force` + `check` (ADO_AW_COMPILE_REMOTE_URL=)
+ assertions: ADO token isolation, NO TRIGGERS, manifest agentCommand,
+ + mode-specific release-URL / pipeline-artifact assertions
+ cp .lock.yml -> .smoke/pipeline.yml
+ kind=raw:
+ cp -> .smoke/pipeline.yml
+ assertions: NO TRIGGERS
+ changed-paths allowlist guard (per case)
+ commitAll -> push HEAD:refs/heads/ado-aw-smoke-candidate//
+ verifyRemoteRef
+ git reset --hard
+```
+
+Per-case allowlist: `.smoke/pipeline.yml`, `.gitattributes`,
+`.ado-aw/imports/**`, plus (compiled only) that case's own `.md` and the
+generated `.lock.yml`. Strictly tighter than today's union-of-five allowlist.
+
+Note the generated `.lock.yml` is now purely an intermediate that dies with the
+ref — nothing is ever committed back to GitHub.
+
+### Trigger hardening (load-bearing under a shared path)
+
+Verified: the compiler already emits `trigger: none` / `pr: none` by default,
+and emits `schedules:` only from `on.schedule`. But a case declaring
+`on: pr:`/`on: push:` would compile a real trigger — and because every case in a
+lane shares one definition and one path, pushing that case's ref would
+CI-trigger the lane *in addition to* the API-queued run.
+
+1. `injectPipelineArtifact` (renamed `prepareCaseSource`) strips the entire
+ `on:` block in **both** modes. This also removes each case's schedule, which
+ is now owned by the orchestrator rather than by the child.
+2. New `assertNoTriggers(yamlText, label)`: require top-level `trigger: none`
+ and `pr: none`; reject any `schedules:` key or `resources.pipelines[].trigger`.
+ Runs for `compiled` and `raw` cases alike, before push.
+
+### Ref model
+
+- `candidateRef(buildId, caseId)` → `refs/heads/ado-aw-smoke-candidate//`.
+- `parseCandidateRef(ref)` → `{ buildId, caseId } | undefined`, replacing
+ `parseCandidateBuildId`'s `^[0-9]+$` suffix test with
+ `^([0-9]+)/([a-z0-9][a-z0-9-]{0,48})$`. Anything else stays `ambiguous` and is
+ never deleted — the fail-closed posture is preserved.
+- `listCandidateRefs` glob widened to `refs/heads//**`; the existing
+ client-side `startsWith(prefix)` guard remains the real filter.
+- `deleteRemoteRefs(refs[])` batches into one `git push --delete url ref1 ref2 …`
+ with per-ref fallback.
+- **Granularity win:** each case's ref is deleted iff *that case's*
+ `terminalProven` is true. Today one unproven child retains the single shared
+ ref for everything.
+
+### Lane queueing
+
+`FixtureBuildRequest` becomes `{ caseId, lane, definitionId, sourceBranch,
+sourceVersion }`, where `definitionId` is the lane id — so several requests
+legitimately share it. `runner.ts`'s `describeMismatch` identity check still
+works and is strengthened in practice: `sourceBranch` is now unique per case, so
+it alone disambiguates.
+
+`stale.ts` `childDefinitionIds` becomes the registered lane definition ids.
+
+### Assertions become declarative
+
+`index.ts` hardcodes `if (fixture.name === "azure-cli")` and `fixtures.ts`
+hardcodes `requiredBuildTags` for `custom-safe-output`. Both move into the
+manifest, so a new case with either need touches JSON, not TypeScript.
+
+### Orchestrators
+
+Shared steps template `tests/smoke/orchestrator-steps.yml`, consumed by two thin
+root pipelines so their trigger blocks can't leak into one another:
+
+| Root YAML | Definition | Triggers | Mode |
+| --- | --- | --- | --- |
+| `tests/smoke/azure-pipelines-candidate.yml` | `2559` (existing) | PR (comment-gated) + nightly 01:00 UTC | `candidate` |
+| `tests/smoke/azure-pipelines-release.yml` | new | scheduled daily; `trigger: none`, `pr: none` | `released` |
+
+The Rust build steps are `condition:`-gated on the mode variable, so released
+mode skips the toolchain install and compiler build entirely.
+
+**Janitor scheduling:** rather than reproduce per-case cron semantics, `janitor`
+runs on every released-mode run (daily instead of weekly). Its prune window is
+"older than 30 days", so it is idempotent and running it more often is
+strictly safer than running it less. This avoids needing per-schedule template
+parameters.
+
+### ADO target state
+
+| Definition | Repo | `yamlFilename` | Default branch | Secrets | Service connections |
+| --- | --- | --- | --- | --- | --- |
+| smoke lane `agentic` | `ado-aw-mirror` | `/.smoke/pipeline.yml` | `refs/heads/ado-aw-smoke-candidate-base` | `GITHUB_TOKEN`, `ADO_AW_GITHUB_TOKEN` | `agent-playground-read/write` |
+| smoke lane `infra` | `ado-aw-mirror` | `/.smoke/pipeline.yml` | same | none | none |
+| release orchestrator | `githubnext/ado-aw` | `tests/smoke/azure-pipelines-release.yml` | `main` | none | `githubnext`, `agent-playground-write` |
+| queue target | `githubnext/ado-aw` | `tests/executor-e2e/queue-target.yml` | `main` | none | `githubnext` |
+
+All lane definitions: no CI trigger, no PR trigger, no schedule — API-queued
+only, and added to `trigger-policy.json`'s `scheduled_only_definition_ids`.
+
+Base ref `refs/heads/ado-aw-smoke-candidate-base` gains `.smoke/pipeline.yml`
+(the existing `inert-child.yml` content); the five old placeholder lock paths are
+removed in the same commit.
+
+Run readability under a shared definition is already solved — each compiled lock
+carries its own `name:` (e.g. `Daily safe-output smoke canary-$(BuildID)`).
+`smoke-case:` / `smoke-candidate:` build tags are added at queue
+time for filtering and scanner correlation.
+
+## Dependencies discovered — must not break
+
+1. **`E2E_QUEUE_PIPELINE_ID=2547`.** The executor-e2e `queue-build` scenario
+ (definition `2550`) queues the `noop-target` definition. Retiring 2545–2549
+ would silently break it — and a lane definition is not a valid substitute,
+ because on its default branch it hits the inert placeholder, which fails by
+ design.
+ **Resolution:** add a permanent, static, non-agentic
+ `tests/executor-e2e/queue-target.yml` (`trigger: none`, one echo step),
+ register it, and repoint `E2E_QUEUE_PIPELINE_ID`. `noop-target` remains a
+ smoke *case* for behavioural coverage; the queue *target* becomes a separate
+ trivial fixture. This is a simplification — the scenario only ever needed a
+ queueable definition, not an agentic pipeline.
+
+2. **The weekly janitor.** Definition `2548` prunes `ado-aw-smoke-*` artifacts
+ from AgentPlayground. Retiring it without replacement lets the sandbox fill
+ up indefinitely.
+ **Resolution:** `janitor` becomes a released-mode case (see above).
+
+## Coverage delta (accepted)
+
+| Property | Before | After |
+| --- | --- | --- |
+| Release assets exist / are downloadable | committed lock's integrity step | **retained** — orchestrator downloads a released asset, and every released-mode child downloads again |
+| Released compiler output runs end to end | committed lock | **retained** — recompiled at stage time by the released binary |
+| Runtime integrity check passes | committed lock | **retained** — the child still recompiles from the staged `.md` and compares |
+| Committed lock matches released compiler (`ado-aw check` drift) | `recompile-safe-output-fixtures` | **dissolved** — nothing committed, so no drift is possible |
+| **Pipelines run from a GitHub-backed definition with real GitHub metadata** | 2545–2549 | **LOST** — every smoke now runs from `ado-aw-mirror` with mirror metadata |
+| **The exact committed bytes a customer would commit are executed** | 2545–2549 | **LOST** |
+| Repo dogfoods the commit-the-lock customer workflow | `tests/safe-outputs/` | **LOST** here; still exercised by `.github/workflows/*.lock.yml` (gh-aw) |
+
+The two genuine losses are both about GitHub-backed, committed-artifact
+execution. Cheapest future mitigation if a metadata regression ever escapes:
+re-add a *single* GitHub-backed canary with a committed lock. Deliberately not
+done now.
+
+## Todos
+
+Every code/docs todo below is **done**; items 22–25 are the ADO-side work
+summarised under *Remaining work* at the top.
+
+1. **manifest-schema** — ✅ `tests/smoke/cases.json` (two lanes, seven cases,
+ `modes`, declarative assertions).
+2. **manifest-loader** — ✅ `cases.ts`: strict fail-closed validation and
+ `loadCases(worktreeDir, env, mode)`.
+3. **config-split** — ✅ `config.ts` reduced to env-only;
+ `candidateRef(buildId, caseId)`; `SMOKE_COMPILER_SOURCE` parsing.
+4. **mode-plumbing** — ✅ artifact injection skipped in released mode;
+ release-URL assertion inverted; artifact assertion skipped.
+5. **strip-on-block** — ✅ whole `on:` block stripped;
+ `injectPipelineArtifact` → `prepareCaseSource`.
+6. **assert-no-triggers** — ✅ `assertNoTriggers` + `assertReleaseUrlsPresent`.
+7. **declarative-assertions** — ✅ `agentCommand` / `requiredBuildTags` driven
+ from the manifest; `fixture.name ===` branches deleted.
+8. **fixed-path-staging** — ✅ per-case stage/commit/push/reset;
+ `fixtures.ts` retired.
+9. **raw-kind** — ✅ verbatim copy path, still trigger-asserted.
+10. **ref-model** — ✅ `parseCandidateRef`, widened glob, batched
+ `deleteRemoteRefs`.
+11. **per-case-cleanup** — ✅ refs deleted per case on proven-terminal.
+12. **lane-queueing** — ✅ `runner.ts` keyed by `caseId` + `lane`.
+13. **stale-scanner** — ✅ new ref pattern; `laneDefinitionIds`.
+14. **queue-tags** — ✅ `smoke-case:` / `smoke-candidate:` via `addBuildTags`.
+15. **orchestrator-split** — ✅ `orchestrator-steps.yml` +
+ `orchestrator-variables.yml` + two mode-specific roots.
+16. **queue-target** — ✅ `tests/executor-e2e/queue-target.yml` + README.
+17. **delete-locks** — ✅ five locks deleted; smoke dir moved to `tests/smoke/`.
+18. **retire-recompile-workflow** — ✅ workflow and its release dispatch job
+ removed.
+19. **trigger-policy** — ✅ path fixed, `_note` documents the pending ids.
+20. **unit-tests** — ✅ 270 harness tests (15 files), incl. new `cases.test.ts`.
+21. **docs** — ✅ both smoke READMEs, `REGISTERED.md`, `AGENTS.md`,
+ `docs/ado-script.md`, executor-e2e README, and the two review workflows.
+22. **ado-runbook** — ✅ written (`tests/smoke/REGISTERED.md`).
+23. **ado-apply** — ⏳ requires AgentPlayground access.
+24. **live-validation** — ⏳ requires registered definitions.
+25. **retire-old-defs** — ⏳ after live validation.
+
+### Dependencies
+
+```
+manifest-schema ──> manifest-loader ──> config-split ──┬─> mode-plumbing ──┐
+ ├─> fixed-path-staging ──> raw-kind ──┐
+strip-on-block ──> assert-no-triggers ─────────────────┤ │
+declarative-assertions ────────────────────────────────┤ │
+ref-model ──> per-case-cleanup ────────────────────────┤ │
+lane-queueing ──> stale-scanner ───────────────────────┤ │
+queue-tags ────────────────────────────────────────────┴──> orchestrator-split ──────────────┤
+ │
+delete-locks ──> retire-recompile-workflow │
+queue-target ────────────────────────────────────────────────────────────────────────────────┤
+ trigger-policy ─────────┤
+ unit-tests ─────────────┤
+ docs ────────────────────┤
+ ado-runbook ──> ado-apply ──────────────┤
+ ▼
+ live-validation
+ ▼
+ retire-old-defs
+```
+
+## Validation
+
+**Local (deterministic, no ADO):**
+
+```bash
+cd scripts/ado-script
+npm ci
+npm run typecheck
+npx vitest run src/compiler-smoke-e2e
+npm run build:compiler-smoke-e2e
+```
+
+Plus `cargo test` — several Rust tests reference `tests/safe-outputs/` paths and
+must be checked after `delete-locks`.
+
+**Live contract:**
+
+| # | Assertion | Mode |
+| --- | --- | --- |
+| 1 | Producer remains in progress after publishing its artifact | candidate |
+| 2 | Every child downloads the exact producer `run-id` | candidate |
+| 3 | Every child downloads released assets from GitHub Releases | released |
+| 4 | All in-mode cases succeed | both |
+| 5 | `custom-safe-output` carries `ado-aw-custom-job-` | candidate |
+| 6 | Exactly one ref per case created; every ref deleted | both |
+| 7 | Each build ran the lane definition on that case's own ref | both |
+| 8 | Queued build count == case count (no ref push CI-triggered a lane) | both |
+| 9 | `queue-build` executor-e2e scenario passes against the new queue target | n/a |
+
+## Risks
+
+| Risk | Mitigation |
+| --- | --- |
+| Big-bang cutover removes **both** existing smoke signals at once | Manual live run of both orchestrators before the old definitions are deleted; the new lanes must be proven green first, since deletion is not reversible |
+| Loss of GitHub-backed / committed-artifact execution | Accepted (decision 4). Re-add a single GitHub-backed canary if a metadata regression escapes |
+| `E2E_QUEUE_PIPELINE_ID` breakage | Explicit `queue-target` todo; live assertion #9 |
+| Janitor stops pruning; AgentPlayground fills up | Janitor becomes a daily released-mode case; idempotent 30-day window |
+| GitHub PAT reaches the agent | Compiler confines it to the Stage 3 executor; `assertAdoTokenIsolation` fails the run on freshly compiled YAML if it appears in Agent or Detection |
+| Credential creep into the credential-free lane | `infra` holds nothing, and a manifest test asserts it carries no cases |
+| A case with a stray trigger double-queues its whole lane | `assertNoTriggers` + full `on:` strip, both fail-closed before push |
+| Malicious/typo `caseId` injected into a git ref name | Strict `^[a-z0-9][a-z0-9-]{0,48}$` at manifest load, before any git call |
+| Released mode silently degrades to candidate behaviour | Inverted release-URL assertion makes a missing release URL a hard failure |
+| Parallel-job exhaustion as case count grows | `COMPILER_SMOKE_CONCURRENCY` retained; raise deliberately |
+| Sequential per-case compile lengthens staging | Measure during live validation; parallelise the compile phase only if material |
+
+## Notes
+
+- `ado-aw-mirror` is **not** a mirror of GitHub — nothing syncs into it. It
+ holds only the permanent inert base ref plus ephemeral per-run refs.
+ Conceptually a *staging repo*; renaming it is out of scope.
+- Candidate commits are built from the **local GitHub checkout** at
+ `BUILD_SOURCEVERSION` and pushed only to ADO. `verifyLocalCommit`'s
+ no-mirror-fetch rule (PR merge refs don't exist on the mirror) is unchanged.
+- After this change `tests/safe-outputs/` is markdown-only; consolidating it
+ into `tests/smoke/` is a natural follow-up but is kept out of scope to limit
+ path churn in one PR.
+- Resolving lane ids by name over REST instead of env vars was considered and
+ dropped: with three stable lanes it is no longer a per-case cost.
diff --git a/docs/ado-script.md b/docs/ado-script.md
index 01cf5293..a488adae 100644
--- a/docs/ado-script.md
+++ b/docs/ado-script.md
@@ -113,13 +113,15 @@ pipeline** as runtime helpers. Today it produces thirteen bundles:
> Rust-side `Fact::ALL` registry, catching drift at CI time.
> **Test-only, not shipped: `compiler-smoke-e2e`.** The workspace also contains a
-> `src/compiler-smoke-e2e/` harness that drives the deterministic compiler-candidate
-> smoke E2E suite (see [`tests/compiler-smoke-e2e/`](../tests/compiler-smoke-e2e/)).
-> It stages the compiler candidate produced by the current build (PR or nightly `main`)
-> as a pinned `supply-chain.pipeline-artifact` source across the five real fixtures in
-> `tests/safe-outputs/`, pushes the staged candidate to a short-lived `ado-aw-mirror`
-> branch on the mirror repo, queues the five FIXED "candidate lane" pipeline definitions
-> (tracked in `tests/compiler-smoke-e2e/REGISTERED.md`), and asserts they all go green.
+> `src/compiler-smoke-e2e/` harness that drives the smoke E2E suite (see
+> [`tests/smoke/`](../tests/smoke/)). It stages every case declared in
+> `tests/smoke/cases.json` to the fixed `.smoke/pipeline.yml` path on its own
+> per-case `ado-aw-mirror` ref, then queues each against its credential *lane*
+> definition (tracked in `tests/smoke/REGISTERED.md`) and asserts they all go
+> green. Two modes, selected by `SMOKE_COMPILER_SOURCE`: `candidate` pins every
+> case to the compiler artifact built by the current run, while `released`
+> compiles with the latest release asset and requires release URLs to survive
+> into the output.
> It is **not** a runtime bundle: it is built to the non-root `test-bin/compiler-smoke-e2e.js`
> by `npm run build:compiler-smoke-e2e` (kept out of the main `build` chain and the
> release `ado-script/*.js` glob), and `compiler-smoke-e2e` is listed in `NON_BUNDLE_DIRS`
@@ -580,7 +582,7 @@ scripts/ado-script/
│ │ ├── fact-catalog.gen.json # generated by `cargo run -- export-fact-catalog`; deep-compared by gate-spec.test.ts
│ │ └── __tests__/ # gate-spec drift tests and trigger-evaluation scenario tests
│ ├── executor-e2e/ # test-only: Stage 3 safe-output E2E harness (not a bundle; built to test-bin/executor-e2e.js)
-│ └── compiler-smoke-e2e/ # test-only: deterministic compiler-candidate smoke E2E orchestrator (not a bundle; built to test-bin/ by build:compiler-smoke-e2e)
+│ └── compiler-smoke-e2e/ # test-only: lane-based smoke E2E orchestrator (not a bundle; built to test-bin/ by build:compiler-smoke-e2e)
├── test/ # End-to-end smoke tests (gate, import, exec-context-pr)
├── gate.js # ncc bundle output (gitignored)
├── import.js # ncc bundle output (gitignored)
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/assertions.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/assertions.test.ts
index 39b09ed7..86e382ef 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/assertions.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/assertions.test.ts
@@ -4,7 +4,9 @@ import {
assertAgentCommandPolicy,
assertAdoTokenIsolation,
assertNoForbiddenReleaseUrls,
+ assertNoTriggers,
assertPipelineArtifactValues,
+ assertReleaseUrlsPresent,
} from "../assertions.js";
const EXPECTED = {
@@ -62,6 +64,7 @@ describe("assertAdoTokenIsolation", () => {
"SC_READ_TOKEN",
"SC_WRITE_TOKEN",
"SYSTEM_ACCESSTOKEN",
+ "ADO_AW_GITHUB_TOKEN",
])("rejects %s on the Agent", (credential) => {
expect(() =>
assertAdoTokenIsolation(
@@ -78,6 +81,7 @@ describe("assertAdoTokenIsolation", () => {
"SC_READ_TOKEN",
"SC_WRITE_TOKEN",
"SYSTEM_ACCESSTOKEN",
+ "ADO_AW_GITHUB_TOKEN",
])("rejects %s on Detection", (credential) => {
expect(() =>
assertAdoTokenIsolation(
@@ -88,6 +92,13 @@ describe("assertAdoTokenIsolation", () => {
),
).toThrow(new RegExp(`Detection must not receive ${credential}`));
});
+
+ it("still allows GITHUB_TOKEN, which is Copilot CLI auth and not a write credential", () => {
+ // The base fixture already maps GITHUB_TOKEN into both steps. Pinning it
+ // explicitly so a future tightening cannot break every agentic case by
+ // conflating Copilot auth with the external-write PAT.
+ expect(() => assertAdoTokenIsolation(agentTokenYaml(), "canary")).not.toThrow();
+ });
});
describe("assertAgentCommandPolicy", () => {
@@ -213,3 +224,98 @@ stages:
expect(() => assertPipelineArtifactValues(yaml, "canary", EXPECTED)).not.toThrow();
});
});
+
+describe("assertReleaseUrlsPresent", () => {
+ it("passes when the compiled pipeline downloads a released asset", () => {
+ expect(() =>
+ assertReleaseUrlsPresent(
+ "steps:\n - bash: curl -L https://github.com/githubnext/ado-aw/releases/download/v1/ado-aw\n",
+ "canary",
+ ),
+ ).not.toThrow();
+ });
+
+ it("accepts the AWF release URL alone", () => {
+ expect(() =>
+ assertReleaseUrlsPresent(
+ "steps:\n - bash: curl -L https://github.com/github/gh-aw-firewall/releases/download/v1/awf\n",
+ "canary",
+ ),
+ ).toBeTruthy();
+ });
+
+ it("fails closed when released mode silently stopped downloading release assets", () => {
+ // Without this, a released-mode run that accidentally pinned a pipeline
+ // artifact would go green while testing nothing about release packaging.
+ expect(() => assertReleaseUrlsPresent("steps:\n - bash: echo hi\n", "canary")).toThrow(
+ /references no release URL/,
+ );
+ });
+
+ it("is the exact mirror image of assertNoForbiddenReleaseUrls", () => {
+ const withRelease =
+ "steps:\n - bash: curl -L https://github.com/githubnext/ado-aw/releases/download/v1/ado-aw\n";
+ expect(() => assertNoForbiddenReleaseUrls(withRelease, "x")).toThrow();
+ expect(() => assertReleaseUrlsPresent(withRelease, "x")).not.toThrow();
+
+ const withoutRelease = "steps:\n - bash: echo hi\n";
+ expect(() => assertNoForbiddenReleaseUrls(withoutRelease, "x")).not.toThrow();
+ expect(() => assertReleaseUrlsPresent(withoutRelease, "x")).toThrow();
+ });
+});
+
+describe("assertNoTriggers", () => {
+ const clean = "trigger: none\npr: none\njobs:\n - job: Agent\n";
+
+ it("passes for a pipeline that declares trigger: none and pr: none", () => {
+ expect(() => assertNoTriggers(clean, "canary")).not.toThrow();
+ });
+
+ it("rejects a CI trigger", () => {
+ // Load-bearing: every case in a lane shares one definition AND one YAML
+ // path, so a surviving trigger would make the ref push CI-trigger the lane
+ // on top of the API-queued run.
+ expect(() =>
+ assertNoTriggers("trigger:\n branches:\n include:\n - main\npr: none\n", "canary"),
+ ).toThrow(/must declare 'trigger: none'/);
+ });
+
+ it("rejects a PR trigger", () => {
+ expect(() =>
+ assertNoTriggers("trigger: none\npr:\n branches:\n include:\n - main\n", "canary"),
+ ).toThrow(/must declare 'pr: none'/);
+ });
+
+ it("rejects a missing trigger key rather than assuming a safe default", () => {
+ expect(() => assertNoTriggers("pr: none\njobs: []\n", "canary")).toThrow(
+ /must declare 'trigger: none'/,
+ );
+ });
+
+ it("rejects a schedules block", () => {
+ expect(() =>
+ assertNoTriggers(
+ "trigger: none\npr: none\nschedules:\n - cron: '0 3 * * *'\n",
+ "canary",
+ ),
+ ).toThrow(/must not declare 'schedules:'/);
+ });
+
+ it("rejects a pipeline resource trigger", () => {
+ expect(() =>
+ assertNoTriggers(
+ "trigger: none\npr: none\nresources:\n pipelines:\n - pipeline: up\n source: Other\n trigger: true\n",
+ "canary",
+ ),
+ ).toThrow(/resources\.pipelines\[\]\.trigger/);
+ });
+
+ it("allows a pipeline resource without a trigger", () => {
+ expect(() =>
+ assertNoTriggers(
+ "trigger: none\npr: none\nresources:\n pipelines:\n - pipeline: up\n source: Other\n",
+ "canary",
+ ),
+ ).not.toThrow();
+ });
+});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cases.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cases.test.ts
new file mode 100644
index 00000000..5759bba3
--- /dev/null
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cases.test.ts
@@ -0,0 +1,384 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { expandBuildTag, parseManifest } from "../cases.js";
+
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "..");
+const REAL_MANIFEST_PATH = join(REPO_ROOT, "tests", "smoke", "cases.json");
+
+/** A minimal valid manifest; individual tests override one field at a time. */
+function manifest(overrides: Record = {}): string {
+ return JSON.stringify({
+ schema: "ado-aw/smoke-cases/1",
+ yamlPath: ".smoke/pipeline.yml",
+ lanes: {
+ agentic: { definitionIdEnv: "SMOKE_LANE_AGENTIC_DEFINITION_ID" },
+ debug: { definitionIdEnv: "SMOKE_LANE_DEBUG_DEFINITION_ID" },
+ },
+ cases: [
+ {
+ id: "canary",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate", "released"],
+ source: "tests/safe-outputs/canary.md",
+ },
+ ],
+ ...overrides,
+ });
+}
+
+function cases(entries: unknown[]): string {
+ return manifest({ cases: entries });
+}
+
+describe("parseManifest", () => {
+ it("parses a minimal valid manifest", () => {
+ const parsed = parseManifest(manifest());
+ expect(parsed.yamlPath).toBe(".smoke/pipeline.yml");
+ expect(parsed.cases).toHaveLength(1);
+ expect(parsed.cases[0]).toMatchObject({ id: "canary", lane: "agentic", kind: "compiled" });
+ });
+
+ it("rejects an unsupported schema", () => {
+ expect(() => parseManifest(manifest({ schema: "ado-aw/smoke-cases/2" }))).toThrow(
+ /unsupported schema/,
+ );
+ });
+
+ it("rejects malformed JSON", () => {
+ expect(() => parseManifest("{not json")).toThrow(/not valid JSON/);
+ });
+
+ describe("case id validation (becomes a git ref segment)", () => {
+ // The id is interpolated into `refs/heads///`, so
+ // anything that could smuggle extra path components, ref options, or shell
+ // metacharacters must be rejected before git ever sees it.
+ for (const id of [
+ "../evil",
+ "a/b",
+ "UPPER",
+ "trailing-",
+ "-leading",
+ "has space",
+ "semi;colon",
+ "dot.dot",
+ "under_score",
+ "",
+ "a".repeat(50),
+ ]) {
+ it(`rejects ${JSON.stringify(id)}`, () => {
+ expect(() =>
+ parseManifest(
+ cases([{ id, lane: "agentic", kind: "compiled", modes: ["candidate"], source: "a.md" }]),
+ ),
+ ).toThrow();
+ });
+ }
+
+ it("accepts lowercase alphanumerics with interior hyphens", () => {
+ const parsed = parseManifest(
+ cases([
+ {
+ id: "custom-safe-output-2",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate", "released"],
+ source: "a.md",
+ },
+ ]),
+ );
+ expect(parsed.cases[0]?.id).toBe("custom-safe-output-2");
+ });
+
+ it("rejects duplicate ids", () => {
+ const entry = {
+ id: "canary",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate"],
+ source: "a.md",
+ };
+ expect(() => parseManifest(cases([entry, { ...entry }]))).toThrow(/duplicate case id/);
+ });
+ });
+
+ describe("source path validation", () => {
+ for (const source of [
+ "../outside.md",
+ "tests/../../etc/passwd.md",
+ "/absolute.md",
+ "C:/windows.md",
+ "back\\slash.md",
+ "tests//double.md",
+ "./relative.md",
+ ]) {
+ it(`rejects ${JSON.stringify(source)}`, () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ { id: "x", lane: "agentic", kind: "compiled", modes: ["candidate"], source },
+ ]),
+ ),
+ ).toThrow();
+ });
+ }
+ });
+
+ describe("kind / extension agreement", () => {
+ it("rejects a compiled case whose source is not markdown", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ {
+ id: "x",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate"],
+ source: "tests/smoke/raw.yml",
+ },
+ ]),
+ ),
+ ).toThrow(/must end in \.md/);
+ });
+
+ it("rejects a raw case whose source is markdown", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ {
+ id: "x",
+ lane: "agentic",
+ kind: "raw",
+ modes: ["candidate"],
+ source: "tests/smoke/a.md",
+ },
+ ]),
+ ),
+ ).toThrow(/must end in \.yml or \.yaml/);
+ });
+
+ it("accepts a raw case with a .yml source", () => {
+ const parsed = parseManifest(
+ cases([
+ {
+ id: "awf",
+ lane: "agentic",
+ kind: "raw",
+ modes: ["candidate", "released"],
+ source: "tests/smoke/awf/pipeline.yml",
+ },
+ ]),
+ );
+ expect(parsed.cases[0]?.kind).toBe("raw");
+ });
+
+ it("rejects an unknown kind", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ { id: "x", lane: "agentic", kind: "magic", modes: ["candidate"], source: "a.md" },
+ ]),
+ ),
+ ).toThrow(/kind 'magic'/);
+ });
+ });
+
+ describe("lane and mode validation", () => {
+ it("rejects a case referencing an unknown lane", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ { id: "x", lane: "nope", kind: "compiled", modes: ["candidate"], source: "a.md" },
+ ]),
+ ),
+ ).toThrow(/unknown lane 'nope'/);
+ });
+
+ it("rejects an unknown mode", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ { id: "x", lane: "agentic", kind: "compiled", modes: ["nightly"], source: "a.md" },
+ ]),
+ ),
+ ).toThrow(/mode 'nightly'/);
+ });
+
+ it("rejects an empty modes array", () => {
+ expect(() =>
+ parseManifest(
+ cases([{ id: "x", lane: "agentic", kind: "compiled", modes: [], source: "a.md" }]),
+ ),
+ ).toThrow(/modes must not be empty/);
+ });
+
+ it("rejects duplicate modes", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ {
+ id: "x",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate", "candidate"],
+ source: "a.md",
+ },
+ ]),
+ ),
+ ).toThrow(/duplicates/);
+ });
+
+ it("rejects a manifest where a mode has no cases at all", () => {
+ // Otherwise a typo could silently reduce an orchestrator to a no-op that
+ // still reports success.
+ expect(() =>
+ parseManifest(
+ cases([
+ { id: "x", lane: "agentic", kind: "compiled", modes: ["candidate"], source: "a.md" },
+ ]),
+ ),
+ ).toThrow(/no case participates in mode 'released'/);
+ });
+
+ it("rejects two lanes sharing one definitionIdEnv", () => {
+ expect(() =>
+ parseManifest(
+ manifest({
+ lanes: {
+ agentic: { definitionIdEnv: "SHARED_ID" },
+ debug: { definitionIdEnv: "SHARED_ID" },
+ },
+ }),
+ ),
+ ).toThrow(/share definitionIdEnv/);
+ });
+
+ it("rejects a definitionIdEnv that is not an env var name", () => {
+ expect(() =>
+ parseManifest(manifest({ lanes: { agentic: { definitionIdEnv: "lower-case" } } })),
+ ).toThrow(/must match/);
+ });
+ });
+
+ describe("assertions validation", () => {
+ it("rejects an unsupported build tag placeholder", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ {
+ id: "x",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate", "released"],
+ source: "a.md",
+ assertions: { requiredBuildTags: ["tag-{buildid}"] },
+ },
+ ]),
+ ),
+ ).toThrow(/unsupported placeholder/);
+ });
+
+ it("accepts the {buildId} placeholder", () => {
+ const parsed = parseManifest(
+ cases([
+ {
+ id: "x",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate", "released"],
+ source: "a.md",
+ assertions: { requiredBuildTags: ["ado-aw-custom-job-{buildId}"] },
+ },
+ ]),
+ );
+ expect(parsed.cases[0]?.assertions?.requiredBuildTags).toEqual([
+ "ado-aw-custom-job-{buildId}",
+ ]);
+ });
+
+ it("rejects an empty assertions object", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ {
+ id: "x",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate", "released"],
+ source: "a.md",
+ assertions: {},
+ },
+ ]),
+ ),
+ ).toThrow(/must declare agentCommand and\/or requiredBuildTags/);
+ });
+
+ it("rejects an agentCommand with no snippets", () => {
+ expect(() =>
+ parseManifest(
+ cases([
+ {
+ id: "x",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate", "released"],
+ source: "a.md",
+ assertions: { agentCommand: { required: [], forbidden: [] } },
+ },
+ ]),
+ ),
+ ).toThrow(/at least one snippet/);
+ });
+ });
+});
+
+describe("expandBuildTag", () => {
+ it("substitutes every occurrence of {buildId}", () => {
+ expect(expandBuildTag("ado-aw-custom-job-{buildId}", 42)).toBe("ado-aw-custom-job-42");
+ expect(expandBuildTag("{buildId}-{buildId}", 7)).toBe("7-7");
+ });
+
+ it("leaves a tag with no placeholder unchanged", () => {
+ expect(expandBuildTag("static-tag", 42)).toBe("static-tag");
+ });
+});
+
+describe("the real shipped tests/smoke/cases.json", () => {
+ const parsed = parseManifest(readFileSync(REAL_MANIFEST_PATH, "utf8"));
+
+ it("parses under the same strict rules applied to synthetic manifests", () => {
+ expect(parsed.cases.length).toBeGreaterThan(0);
+ });
+
+ it("stages every case to one fixed path so lanes can be shared", () => {
+ expect(parsed.yamlPath).toBe(".smoke/pipeline.yml");
+ });
+
+ it("keeps the credential-free infra lane free of cases", () => {
+ // `infra` is the remaining credential boundary: it holds no GITHUB_TOKEN,
+ // no ADO_AW_GITHUB_TOKEN and no service connections, so the AWF and
+ // ado-proxy smokes can run without any credential in reach. A case landing
+ // here by accident would silently fail at Stage 3 rather than leak, but
+ // the reverse — quietly provisioning `infra` with a token to make such a
+ // case pass — is what would dissolve the boundary.
+ const infraCases = parsed.cases.filter((entry) => entry.lane === "infra");
+ expect(infraCases).toEqual([]);
+ });
+
+ it("declares an infra lane ready for the AWF / ado-proxy smokes", () => {
+ expect(parsed.lanes.map((lane) => lane.id)).toContain("infra");
+ });
+
+ it("runs the candidate-only custom safe-output case in candidate mode only", () => {
+ const custom = parsed.cases.find((entry) => entry.id === "custom-safe-output");
+ expect(custom?.modes).toEqual(["candidate"]);
+ });
+
+ it("covers the janitor in released mode so AgentPlayground keeps being pruned", () => {
+ const janitor = parsed.cases.find((entry) => entry.id === "janitor");
+ expect(janitor?.modes).toEqual(["released"]);
+ });
+});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/config.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/config.test.ts
index 431e742b..6e2eb49c 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/config.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/config.test.ts
@@ -12,15 +12,10 @@ function baseEnv(overrides: Record = {}): NodeJS.Pro
BUILD_SOURCEVERSION: "abc123",
BUILD_SOURCESDIRECTORY: "C:\\repo",
SYSTEM_DEFINITIONID: "99",
- COMPILER_SMOKE_ADO_AW_BIN: "C:\\bin\\ado-aw.exe",
- COMPILER_SMOKE_ARTIFACT_NAME: "ado-aw-candidate",
- COMPILER_SMOKE_MIRROR_REPO: "ado-aw-mirror",
- COMPILER_SMOKE_CANARY_DEFINITION_ID: "2601",
- COMPILER_SMOKE_AZURE_CLI_DEFINITION_ID: "2602",
- COMPILER_SMOKE_NOOP_TARGET_DEFINITION_ID: "2603",
- COMPILER_SMOKE_REPORTER_DEFINITION_ID: "2604",
- COMPILER_SMOKE_CUSTOM_SAFE_OUTPUT_DEFINITION_ID: "2605",
- COMPILER_SMOKE_MULTI_REPO_DEFINITION_ID: "2606",
+ SMOKE_ADO_AW_BIN: "C:\\bin\\ado-aw.exe",
+ SMOKE_ARTIFACT_NAME: "ado-aw-candidate",
+ SMOKE_MIRROR_REPO: "ado-aw-mirror",
+ SMOKE_COMPILER_SOURCE: "candidate",
...overrides,
};
}
@@ -32,14 +27,7 @@ describe("loadConfig", () => {
expect(config.project).toBe("AgentPlayground");
expect(config.buildId).toBe(42);
expect(config.definitionId).toBe(99);
- expect(config.definitionIds).toEqual({
- canary: 2601,
- "azure-cli": 2602,
- "noop-target": 2603,
- "smoke-failure-reporter": 2604,
- "custom-safe-output": 2605,
- "multi-repo": 2606,
- });
+ expect(config.compilerSource).toBe("candidate");
expect(config.concurrency).toBe(5);
expect(config.childTimeoutMs).toBe(7_200_000);
expect(config.pollMs).toBe(10_000);
@@ -55,14 +43,10 @@ describe("loadConfig", () => {
"BUILD_SOURCEVERSION",
"BUILD_SOURCESDIRECTORY",
"SYSTEM_DEFINITIONID",
- "COMPILER_SMOKE_ADO_AW_BIN",
- "COMPILER_SMOKE_ARTIFACT_NAME",
- "COMPILER_SMOKE_MIRROR_REPO",
- "COMPILER_SMOKE_CANARY_DEFINITION_ID",
- "COMPILER_SMOKE_AZURE_CLI_DEFINITION_ID",
- "COMPILER_SMOKE_NOOP_TARGET_DEFINITION_ID",
- "COMPILER_SMOKE_REPORTER_DEFINITION_ID",
- "COMPILER_SMOKE_CUSTOM_SAFE_OUTPUT_DEFINITION_ID",
+ "SMOKE_ADO_AW_BIN",
+ "SMOKE_ARTIFACT_NAME",
+ "SMOKE_MIRROR_REPO",
+ "SMOKE_COMPILER_SOURCE",
]) {
it(`rejects a missing ${name}`, () => {
expect(() => loadConfig(baseEnv({ [name]: undefined }))).toThrow();
@@ -86,89 +70,78 @@ describe("loadConfig", () => {
});
it("rejects a non-integer fixture definition id", () => {
- expect(() => loadConfig(baseEnv({ COMPILER_SMOKE_REPORTER_DEFINITION_ID: "12.5" }))).toThrow(
- /positive integer/,
- );
+ expect(() => loadConfig(baseEnv({ SMOKE_COMPILER_SOURCE: undefined }))).toThrow(/not set/);
});
- it("rejects duplicate fixture definition ids", () => {
- expect(() =>
- loadConfig(
- baseEnv({
- COMPILER_SMOKE_AZURE_CLI_DEFINITION_ID: "2601",
- }),
- ),
- ).toThrow(/distinct/);
+ it("rejects an unknown compiler source", () => {
+ expect(() => loadConfig(baseEnv({ SMOKE_COMPILER_SOURCE: "nightly" }))).toThrow(
+ /SMOKE_COMPILER_SOURCE must be one of candidate, released/,
+ );
});
- it("reports every duplicated fixture in the error message", () => {
- expect(() =>
- loadConfig(
- baseEnv({
- COMPILER_SMOKE_AZURE_CLI_DEFINITION_ID: "2601",
- COMPILER_SMOKE_REPORTER_DEFINITION_ID: "2603",
- }),
- ),
- ).toThrow(/2601 used by \[canary, azure-cli\].*2603 used by \[noop-target, smoke-failure-reporter\]/);
+ it("accepts the released compiler source", () => {
+ expect(loadConfig(baseEnv({ SMOKE_COMPILER_SOURCE: "released" })).compilerSource).toBe(
+ "released",
+ );
});
- describe("COMPILER_SMOKE_CONCURRENCY bounds", () => {
+ describe("SMOKE_CONCURRENCY bounds", () => {
it("defaults to 5 when unset", () => {
expect(loadConfig(baseEnv()).concurrency).toBe(5);
});
it("accepts the lower bound (1)", () => {
- expect(loadConfig(baseEnv({ COMPILER_SMOKE_CONCURRENCY: "1" })).concurrency).toBe(1);
+ expect(loadConfig(baseEnv({ SMOKE_CONCURRENCY: "1" })).concurrency).toBe(1);
});
it("accepts the upper bound (5)", () => {
- expect(loadConfig(baseEnv({ COMPILER_SMOKE_CONCURRENCY: "5" })).concurrency).toBe(5);
+ expect(loadConfig(baseEnv({ SMOKE_CONCURRENCY: "5" })).concurrency).toBe(5);
});
it("rejects 0", () => {
- expect(() => loadConfig(baseEnv({ COMPILER_SMOKE_CONCURRENCY: "0" }))).toThrow(/range/);
+ expect(() => loadConfig(baseEnv({ SMOKE_CONCURRENCY: "0" }))).toThrow(/range/);
});
- it("rejects 6", () => {
- expect(() => loadConfig(baseEnv({ COMPILER_SMOKE_CONCURRENCY: "6" }))).toThrow(/range/);
+ it("rejects 11", () => {
+ expect(() => loadConfig(baseEnv({ SMOKE_CONCURRENCY: "11" }))).toThrow(/range/);
});
it("rejects a non-integer value", () => {
- expect(() => loadConfig(baseEnv({ COMPILER_SMOKE_CONCURRENCY: "2.5" }))).toThrow(/integer/);
+ expect(() => loadConfig(baseEnv({ SMOKE_CONCURRENCY: "2.5" }))).toThrow(/integer/);
});
});
- describe("COMPILER_SMOKE_CHILD_TIMEOUT_MS", () => {
+ describe("SMOKE_CHILD_TIMEOUT_MS", () => {
it("defaults to 7200000ms", () => {
expect(loadConfig(baseEnv()).childTimeoutMs).toBe(7_200_000);
});
it("accepts an explicit override", () => {
- expect(loadConfig(baseEnv({ COMPILER_SMOKE_CHILD_TIMEOUT_MS: "60000" })).childTimeoutMs).toBe(60_000);
+ expect(loadConfig(baseEnv({ SMOKE_CHILD_TIMEOUT_MS: "60000" })).childTimeoutMs).toBe(60_000);
});
});
- describe("COMPILER_SMOKE_POLL_MS", () => {
+ describe("SMOKE_POLL_MS", () => {
it("defaults to 10000ms", () => {
expect(loadConfig(baseEnv()).pollMs).toBe(10_000);
});
it("accepts an explicit override", () => {
- expect(loadConfig(baseEnv({ COMPILER_SMOKE_POLL_MS: "5000" })).pollMs).toBe(5_000);
+ expect(loadConfig(baseEnv({ SMOKE_POLL_MS: "5000" })).pollMs).toBe(5_000);
});
});
- describe("COMPILER_SMOKE_STALE_REF_HOURS bounds", () => {
+ describe("SMOKE_STALE_REF_HOURS bounds", () => {
it("defaults to 24", () => {
expect(loadConfig(baseEnv()).staleRefHours).toBe(24);
});
it("accepts the minimum (6)", () => {
- expect(loadConfig(baseEnv({ COMPILER_SMOKE_STALE_REF_HOURS: "6" })).staleRefHours).toBe(6);
+ expect(loadConfig(baseEnv({ SMOKE_STALE_REF_HOURS: "6" })).staleRefHours).toBe(6);
});
it("rejects below the minimum (5)", () => {
- expect(() => loadConfig(baseEnv({ COMPILER_SMOKE_STALE_REF_HOURS: "5" }))).toThrow(/range/);
+ expect(() => loadConfig(baseEnv({ SMOKE_STALE_REF_HOURS: "5" }))).toThrow(/range/);
});
});
@@ -183,11 +156,11 @@ describe("loadConfig", () => {
});
describe("candidateRef", () => {
- it("builds the deterministic per-run candidate ref", () => {
- expect(candidateRef(42)).toBe("refs/heads/ado-aw-smoke-candidate/42");
+ it("builds the deterministic per-run, per-case candidate ref", () => {
+ expect(candidateRef(42, "canary")).toBe("refs/heads/ado-aw-smoke-candidate/42/canary");
});
it("never collides with a plausible base ref name", () => {
- expect(candidateRef(1)).not.toBe("refs/heads/main");
+ expect(candidateRef(1, "canary")).not.toBe("refs/heads/main");
});
});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/fixtures.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/fixtures.test.ts
deleted file mode 100644
index 0e378e00..00000000
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/fixtures.test.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import {
- ALL_FIXTURES,
- CANDIDATE_FIXTURE_DIR,
- RELEASE_FIXTURE_DIR,
- allowedChangedPaths,
- fixturePaths,
-} from "../fixtures.js";
-
-describe("fixturePaths", () => {
- it("builds repo-relative md/lock paths under tests/safe-outputs", () => {
- expect(fixturePaths("canary")).toEqual({
- name: "canary",
- relMd: "tests/safe-outputs/canary.md",
- relLock: "tests/safe-outputs/canary.lock.yml",
- });
- });
-
- it("uses the candidate-only directory for candidate-only fixtures", () => {
- const fixture = fixturePaths("custom-safe-output");
- expect(fixture.relMd).toBe(
- "tests/compiler-smoke-e2e/custom-safe-output.md",
- );
- expect(fixture.relLock).toBe(
- "tests/compiler-smoke-e2e/custom-safe-output.lock.yml",
- );
- expect(fixture.requiredBuildTags?.(42)).toEqual(["ado-aw-custom-job-42"]);
-
- const multiRepo = fixturePaths("multi-repo");
- expect(multiRepo.relMd).toBe("tests/compiler-smoke-e2e/multi-repo.md");
- expect(multiRepo.relLock).toBe(
- "tests/compiler-smoke-e2e/multi-repo.lock.yml",
- );
- // Its assertions run inside the pipeline, so it publishes no build tag.
- expect(multiRepo.requiredBuildTags).toBeUndefined();
- });
-});
-
-describe("ALL_FIXTURES", () => {
- it("has exactly the candidate fixtures in the required stable order", () => {
- expect(ALL_FIXTURES.map((f) => f.name)).toEqual([
- "canary",
- "azure-cli",
- "noop-target",
- "smoke-failure-reporter",
- "custom-safe-output",
- "multi-repo",
- ]);
- expect(ALL_FIXTURES.map((f) => f.name)).not.toContain("janitor");
- });
-
- it("keeps release and candidate-only fixture paths separate", () => {
- for (const fixture of ALL_FIXTURES) {
- const directory =
- fixture.name === "custom-safe-output" || fixture.name === "multi-repo"
- ? CANDIDATE_FIXTURE_DIR
- : RELEASE_FIXTURE_DIR;
- expect(fixture.relMd.startsWith(`${directory}/`)).toBe(true);
- expect(fixture.relLock.startsWith(`${directory}/`)).toBe(true);
- }
- });
-});
-
-describe("allowedChangedPaths", () => {
- it("contains every source/lock pair and root compiler-managed attributes", () => {
- const allowed = allowedChangedPaths();
- expect(allowed.size).toBe(ALL_FIXTURES.length * 2 + 1);
- expect(allowed.has(".gitattributes")).toBe(true);
- for (const f of ALL_FIXTURES) {
- expect(allowed.has(f.relMd)).toBe(true);
- expect(allowed.has(f.relLock)).toBe(true);
- }
- });
-
- it("does not allow an arbitrary unrelated path", () => {
- const allowed = allowedChangedPaths();
- expect(allowed.has("src/main.rs")).toBe(false);
- expect(allowed.has("tests/safe-outputs/README.md")).toBe(false);
- });
-});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/git.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/git.test.ts
index 7dabe06e..973d7751 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/git.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/git.test.ts
@@ -10,7 +10,7 @@ import {
disallowedChanges,
listCandidateRefs,
mirrorRepoUrl,
- parseCandidateBuildId,
+ parseCandidateRef,
pushCandidate,
removeWorktree,
verifyLocalCommit,
@@ -56,7 +56,7 @@ describe("mirrorRepoUrl", () => {
describe("commitMessage", () => {
it("matches the required exact format", () => {
- expect(commitMessage(42)).toBe("test(smoke): stage compiler candidate 42");
+ expect(commitMessage(42, "canary")).toBe("test(smoke): stage canary for candidate 42");
});
});
@@ -84,22 +84,22 @@ describe("disallowedChanges", () => {
});
});
-describe("parseCandidateBuildId", () => {
- it("parses the numeric build id from a well-formed candidate ref", () => {
- expect(parseCandidateBuildId("refs/heads/ado-aw-smoke-candidate/123")).toBe(123);
+describe("parseCandidateRef", () => {
+ it("parses the build id and case id from a well-formed candidate ref", () => {
+ expect(parseCandidateRef("refs/heads/ado-aw-smoke-candidate/123/canary")).toEqual({ buildId: 123, caseId: "canary" });
});
it("returns undefined for a ref with the wrong prefix", () => {
- expect(parseCandidateBuildId("refs/heads/main")).toBeUndefined();
+ expect(parseCandidateRef("refs/heads/main")).toBeUndefined();
});
it("returns undefined for a non-numeric suffix", () => {
- expect(parseCandidateBuildId("refs/heads/ado-aw-smoke-candidate/abc")).toBeUndefined();
+ expect(parseCandidateRef("refs/heads/ado-aw-smoke-candidate/abc")).toBeUndefined();
});
it("returns undefined for a zero or negative-looking suffix", () => {
- expect(parseCandidateBuildId("refs/heads/ado-aw-smoke-candidate/0")).toBeUndefined();
- expect(parseCandidateBuildId("refs/heads/ado-aw-smoke-candidate/-5")).toBeUndefined();
+ expect(parseCandidateRef("refs/heads/ado-aw-smoke-candidate/0")).toBeUndefined();
+ expect(parseCandidateRef("refs/heads/ado-aw-smoke-candidate/-5")).toBeUndefined();
});
});
@@ -229,13 +229,13 @@ describe("commitAll", () => {
if (args[0] === "rev-parse") return { status: 0, stdout: "cafebabe\n" };
throw new Error(`unexpected args: ${args.join(" ")}`);
});
- const sha = await commitAll({ worktreeDir: "/wt", buildId: 42, timeoutMs: 1000 }, runner);
+ const sha = await commitAll({ worktreeDir: "/wt", buildId: 42, caseId: "canary", timeoutMs: 1000 }, runner);
expect(sha).toBe("cafebabe");
expect(calls[0]?.args).toEqual(["add", "-A"]);
const commitCall = calls[1]?.args ?? [];
expect(commitCall).toContain(`user.name=${COMMIT_IDENTITY.name}`);
expect(commitCall).toContain(`user.email=${COMMIT_IDENTITY.email}`);
- expect(commitCall).toContain("test(smoke): stage compiler candidate 42");
+ expect(commitCall).toContain("test(smoke): stage canary for candidate 42");
});
});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts
index 2d5ed906..c2834b9c 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts
@@ -1,8 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
const mockCalls: string[] = [];
-const compiledFixturePaths: string[] = [];
-let queuedFixtureNames: string[] = [];
+const compiledCasePaths: string[] = [];
+const stagedWrites: { to: string; contents: string }[] = [];
+let queuedCaseIds: string[] = [];
+let queuedRequests: { caseId: string; lane: string; definitionId: number; sourceBranch: string }[] = [];
+let deletedRefs: string[] = [];
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = join(HERE, "..", "..", "..", "..", "..");
+/** The REAL shipped manifest, so these tests fail if `cases.json` drifts. */
+const REAL_MANIFEST = readFileSync(join(REPO_ROOT, "tests", "smoke", "cases.json"), "utf8");
+
+const WORKTREE = "C:\\tmp\\ado-aw-smoke-xyz";
const baseEnv = {
SYSTEM_COLLECTIONURI: "https://dev.azure.com/org/",
@@ -13,21 +26,32 @@ const baseEnv = {
BUILD_SOURCEVERSION: "basecommit",
BUILD_SOURCESDIRECTORY: "C:\\repo",
SYSTEM_DEFINITIONID: "2560",
- COMPILER_SMOKE_ADO_AW_BIN: "C:\\bin\\ado-aw.exe",
- COMPILER_SMOKE_ARTIFACT_NAME: "ado-aw-candidate",
- COMPILER_SMOKE_MIRROR_REPO: "ado-aw-mirror",
- COMPILER_SMOKE_CANARY_DEFINITION_ID: "3001",
- COMPILER_SMOKE_AZURE_CLI_DEFINITION_ID: "3002",
- COMPILER_SMOKE_NOOP_TARGET_DEFINITION_ID: "3003",
- COMPILER_SMOKE_REPORTER_DEFINITION_ID: "3004",
- COMPILER_SMOKE_CUSTOM_SAFE_OUTPUT_DEFINITION_ID: "3005",
- COMPILER_SMOKE_MULTI_REPO_DEFINITION_ID: "3006",
- COMPILER_SMOKE_CHILD_TIMEOUT_MS: "5000",
- COMPILER_SMOKE_POLL_MS: "1",
+ SMOKE_ADO_AW_BIN: "C:\\bin\\ado-aw.exe",
+ SMOKE_ARTIFACT_NAME: "ado-aw-candidate",
+ SMOKE_MIRROR_REPO: "ado-aw-mirror",
+ SMOKE_COMPILER_SOURCE: "candidate",
+ SMOKE_LANE_AGENTIC_DEFINITION_ID: "3001",
+ SMOKE_LANE_INFRA_DEFINITION_ID: "3003",
+ SMOKE_CHILD_TIMEOUT_MS: "5000",
+ SMOKE_POLL_MS: "1",
};
+/**
+ * Compiled output shaped enough to satisfy every candidate-mode assertion.
+ *
+ * Carries explicit `trigger: none` / `pr: none`, because that is what
+ * `ado-aw compile` emits once `prepareCaseSource` has stripped the `on:`
+ * block — `on:` is the complete declaration of when a pipeline runs, so its
+ * absence compiles to a manual / API-queued-only pipeline. The harness no
+ * longer patches these keys in; `assertNoTriggers` verifies the compiler
+ * produced them, so a compiler that regressed to omitting them (which ADO
+ * reads as "CI on every branch") fails the run instead of silently
+ * double-queueing the lane.
+ */
function specificRunYaml(): string {
return `
+trigger: none
+pr: none
jobs:
- job: Agent
steps:
@@ -61,15 +85,11 @@ vi.mock("../ado-rest.js", () => {
mockCalls.push("getArtifact");
return { name: "ado-aw-candidate" };
}),
- getBuild: vi.fn(async () => ({
- status: "completed",
- result: "succeeded",
- })),
- getBuildTags: vi.fn(async (buildId: number) => [
- `ado-aw-custom-job-${buildId}`,
- ]),
+ getBuild: vi.fn(async () => ({ status: "completed", result: "succeeded" })),
+ getBuildTags: vi.fn(async (buildId: number) => [`ado-aw-custom-job-${buildId}`]),
queueBuild: vi.fn(async () => ({ id: 1 })),
cancelBuild: vi.fn(async () => {}),
+ addBuildTags: vi.fn(async () => {}),
buildUrl: (id: number) => `https://example/${id}`,
};
}),
@@ -91,22 +111,14 @@ vi.mock("../git.js", async (importOriginal) => {
removeWorktree: vi.fn(async () => {
mockCalls.push("removeWorktree");
}),
+ resetWorktree: vi.fn(async () => {
+ mockCalls.push("resetWorktree");
+ }),
+ // Only the paths one case is allowed to touch — the harness resets
+ // between cases, so a per-case commit never sees a sibling's changes.
worktreeChangedFiles: vi.fn(async () => {
mockCalls.push("worktreeChangedFiles");
- return [
- "tests/safe-outputs/canary.md",
- "tests/safe-outputs/canary.lock.yml",
- "tests/safe-outputs/azure-cli.md",
- "tests/safe-outputs/azure-cli.lock.yml",
- "tests/safe-outputs/noop-target.md",
- "tests/safe-outputs/noop-target.lock.yml",
- "tests/safe-outputs/smoke-failure-reporter.md",
- "tests/safe-outputs/smoke-failure-reporter.lock.yml",
- "tests/compiler-smoke-e2e/custom-safe-output.md",
- "tests/compiler-smoke-e2e/custom-safe-output.lock.yml",
- "tests/compiler-smoke-e2e/multi-repo.md",
- "tests/compiler-smoke-e2e/multi-repo.lock.yml",
- ];
+ return [".smoke/pipeline.yml"];
}),
commitAll: vi.fn(async () => {
mockCalls.push("commitAll");
@@ -118,8 +130,9 @@ vi.mock("../git.js", async (importOriginal) => {
verifyRemoteRef: vi.fn(async () => {
mockCalls.push("verifyRemoteRef");
}),
- deleteRemoteRef: vi.fn(async () => {
- mockCalls.push("deleteRemoteRef");
+ deleteRemoteRefs: vi.fn(async (opts: { refs: readonly string[] }) => {
+ mockCalls.push("deleteRemoteRefs");
+ deletedRefs.push(...opts.refs);
}),
listCandidateRefs: vi.fn(async () => {
mockCalls.push("listCandidateRefs");
@@ -131,7 +144,7 @@ vi.mock("../git.js", async (importOriginal) => {
vi.mock("../compile-cli.js", () => ({
compileAndCheck: vi.fn(async (opts: { relMd: string }) => {
mockCalls.push("compileAndCheck");
- compiledFixturePaths.push(opts.relMd);
+ compiledCasePaths.push(opts.relMd);
return { ok: true, stdout: "", stderr: "" };
}),
}));
@@ -141,15 +154,20 @@ vi.mock("../runner.js", async (importOriginal) => {
return {
...actual,
runFixtures: vi.fn(
- async (_client: unknown, requests: { name: string }[]) => {
+ async (
+ _client: unknown,
+ requests: { caseId: string; lane: string; definitionId: number; sourceBranch: string }[],
+ ) => {
mockCalls.push("runFixtures");
- queuedFixtureNames = requests.map((request) => request.name);
+ queuedCaseIds = requests.map((request) => request.caseId);
+ queuedRequests = requests.map((r) => ({ ...r }));
return {
ok: true,
allTerminal: true,
results: requests.map((r) => ({
- name: r.name,
- definitionId: 0,
+ caseId: r.caseId,
+ lane: r.lane,
+ definitionId: r.definitionId,
buildId: 1,
url: "https://example/1",
status: "succeeded" as const,
@@ -167,36 +185,42 @@ vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- mkdtemp: vi.fn(async () => "C:\\tmp\\compiler-smoke-xyz"),
+ mkdtemp: vi.fn(async () => WORKTREE),
+ mkdir: vi.fn(async () => undefined),
readFile: vi.fn(async (path: string) => {
- if (String(path).endsWith(".lock.yml")) {
- return specificRunYaml();
- }
- return "---\nname: fixture\n---\nBody.\n";
+ const p = String(path);
+ // Serve the REAL manifest so these tests exercise the shipped cases.
+ if (p.endsWith("cases.json")) return REAL_MANIFEST;
+ if (p.endsWith(".lock.yml") || p.endsWith(".yml")) return specificRunYaml();
+ return "---\nname: case\n---\nBody.\n";
+ }),
+ writeFile: vi.fn(async (path: string, contents: string) => {
+ const p = String(path);
+ if (p.endsWith("pipeline.yml")) stagedWrites.push({ to: p, contents: String(contents) });
}),
- writeFile: vi.fn(async () => {}),
rm: vi.fn(async () => {}),
};
});
beforeEach(() => {
mockCalls.length = 0;
- compiledFixturePaths.length = 0;
- queuedFixtureNames = [];
+ compiledCasePaths.length = 0;
+ stagedWrites.length = 0;
+ queuedCaseIds = [];
+ queuedRequests = [];
+ deletedRefs = [];
vi.clearAllMocks();
});
-describe("compiler-smoke-e2e index.main (happy path)", () => {
- it("checks artifact visibility before any git work, and deletes the ref before removing the worktree", async () => {
+describe("smoke-e2e index.main (happy path, candidate mode)", () => {
+ it("gates on artifact visibility, stages per case, and deletes refs before removing the worktree", async () => {
process.env = { ...process.env, ...baseEnv, VITEST: "true" };
const { main } = await import("../index.js");
const code = await main();
expect(code).toBe(0);
expect(mockCalls.indexOf("getArtifact")).toBeGreaterThanOrEqual(0);
- expect(mockCalls.indexOf("getArtifact")).toBeLessThan(
- mockCalls.indexOf("verifyLocalCommit"),
- );
+ expect(mockCalls.indexOf("getArtifact")).toBeLessThan(mockCalls.indexOf("verifyLocalCommit"));
expect(mockCalls.indexOf("verifyLocalCommit")).toBeLessThan(
mockCalls.indexOf("createDetachedWorktree"),
);
@@ -206,52 +230,109 @@ describe("compiler-smoke-e2e index.main (happy path)", () => {
expect(mockCalls.indexOf("compileAndCheck")).toBeLessThan(
mockCalls.indexOf("worktreeChangedFiles"),
);
- expect(mockCalls.indexOf("worktreeChangedFiles")).toBeLessThan(
- mockCalls.indexOf("commitAll"),
- );
- expect(mockCalls.indexOf("commitAll")).toBeLessThan(
- mockCalls.indexOf("pushCandidate"),
- );
- expect(mockCalls.indexOf("pushCandidate")).toBeLessThan(
- mockCalls.indexOf("verifyRemoteRef"),
- );
- expect(mockCalls.indexOf("verifyRemoteRef")).toBeLessThan(
- mockCalls.indexOf("runFixtures"),
- );
- expect(compiledFixturePaths).toEqual([
- "tests/safe-outputs/canary.md",
- "tests/safe-outputs/azure-cli.md",
- "tests/safe-outputs/noop-target.md",
- "tests/safe-outputs/smoke-failure-reporter.md",
- "tests/compiler-smoke-e2e/custom-safe-output.md",
- "tests/compiler-smoke-e2e/multi-repo.md",
- ]);
- expect(compiledFixturePaths).not.toContain("tests/safe-outputs/janitor.md");
- expect(queuedFixtureNames).toEqual([
+ expect(mockCalls.indexOf("worktreeChangedFiles")).toBeLessThan(mockCalls.indexOf("commitAll"));
+ expect(mockCalls.indexOf("commitAll")).toBeLessThan(mockCalls.indexOf("pushCandidate"));
+ expect(mockCalls.indexOf("pushCandidate")).toBeLessThan(mockCalls.indexOf("verifyRemoteRef"));
+ expect(mockCalls.indexOf("verifyRemoteRef")).toBeLessThan(mockCalls.indexOf("runFixtures"));
+
+ // Candidate mode runs exactly the cases the manifest declares for it.
+ expect(queuedCaseIds).toEqual([
"canary",
"azure-cli",
"noop-target",
- "smoke-failure-reporter",
"custom-safe-output",
"multi-repo",
]);
- expect(queuedFixtureNames).not.toContain("janitor");
-
- // Cleanup ordering: the remote candidate ref must be deleted BEFORE the
- // local worktree is removed (never leave the ref hanging around).
- expect(mockCalls.indexOf("deleteRemoteRef")).toBeGreaterThanOrEqual(0);
- expect(mockCalls.indexOf("removeWorktree")).toBeGreaterThanOrEqual(0);
- expect(mockCalls.indexOf("deleteRemoteRef")).toBeLessThan(
- mockCalls.indexOf("removeWorktree"),
- );
+ expect(queuedCaseIds).not.toContain("janitor");
+ expect(compiledCasePaths).toEqual([
+ "tests/safe-outputs/canary.md",
+ "tests/safe-outputs/azure-cli.md",
+ "tests/safe-outputs/noop-target.md",
+ "tests/smoke/custom-safe-output.md",
+ "tests/smoke/multi-repo.md",
+ ]);
+
+ // Cleanup ordering: remote refs deleted BEFORE the local worktree is removed.
+ expect(mockCalls.indexOf("deleteRemoteRefs")).toBeGreaterThanOrEqual(0);
+ expect(mockCalls.indexOf("deleteRemoteRefs")).toBeLessThan(mockCalls.indexOf("removeWorktree"));
}, 60_000);
+
+ it("gives every case its own ref and stages all of them to the one fixed path", async () => {
+ process.env = { ...process.env, ...baseEnv, VITEST: "true" };
+ const { main } = await import("../index.js");
+ expect(await main()).toBe(0);
+
+ expect(queuedRequests.map((r) => r.sourceBranch)).toEqual([
+ "refs/heads/ado-aw-smoke-candidate/630001/canary",
+ "refs/heads/ado-aw-smoke-candidate/630001/azure-cli",
+ "refs/heads/ado-aw-smoke-candidate/630001/noop-target",
+ "refs/heads/ado-aw-smoke-candidate/630001/custom-safe-output",
+ "refs/heads/ado-aw-smoke-candidate/630001/multi-repo",
+ ]);
+ // Every case is staged to the SAME path — the ref is what distinguishes them.
+ expect(stagedWrites.length).toBe(5);
+ for (const write of stagedWrites) {
+ expect(write.to).toBe(join(WORKTREE, "candidate", ".smoke", "pipeline.yml"));
+ // The compiler emits no trigger keys once `on:` is stripped, and a
+ // MISSING `trigger:` means "CI on every branch" in ADO — which would
+ // let this ref push queue the shared lane on top of the API-queued run.
+ expect(write.contents).toMatch(/^trigger: none$/m);
+ expect(write.contents).toMatch(/^pr: none$/m);
+ }
+ expect(deletedRefs).toEqual(queuedRequests.map((r) => r.sourceBranch));
+ });
+
+ it("routes every candidate case to its lane definition, not a per-case definition", async () => {
+ process.env = { ...process.env, ...baseEnv, VITEST: "true" };
+ const { main } = await import("../index.js");
+ expect(await main()).toBe(0);
+
+ for (const request of queuedRequests) {
+ expect(request.lane).toBe("agentic");
+ expect(request.definitionId).toBe(3001);
+ }
+ });
+
+ it("resets the worktree between cases so each commit is a sibling of BUILD_SOURCEVERSION", async () => {
+ process.env = { ...process.env, ...baseEnv, VITEST: "true" };
+ const { main } = await import("../index.js");
+ expect(await main()).toBe(0);
+
+ const gitModule = await import("../git.js");
+ const resets = vi.mocked(gitModule.resetWorktree).mock.calls;
+ expect(resets.length).toBe(5);
+ for (const call of resets) {
+ expect(call[0]).toMatchObject({ commitish: "basecommit" });
+ }
+ });
});
-describe("compiler-smoke-e2e index.main (unexpected path guard)", () => {
+describe("smoke-e2e index.main (released mode)", () => {
+ it("skips the artifact gate and runs the released-mode case set", async () => {
+ process.env = {
+ ...process.env,
+ ...baseEnv,
+ SMOKE_COMPILER_SOURCE: "released",
+ VITEST: "true",
+ };
+ const { main } = await import("../index.js");
+ // Released mode asserts release URLs are PRESENT; the fake compiled YAML
+ // has none, so staging is expected to fail closed rather than silently pass.
+ const code = await main();
+ expect(code).toBe(1);
+
+ // The artifact-visibility gate is candidate-only: there is no candidate
+ // artifact to gate on in released mode.
+ expect(mockCalls).not.toContain("getArtifact");
+ expect(mockCalls).toContain("createDetachedWorktree");
+ });
+});
+
+describe("smoke-e2e index.main (unexpected path guard)", () => {
it("refuses to push and never deletes a ref that was never pushed, but still removes the worktree", async () => {
const gitModule = await import("../git.js");
vi.mocked(gitModule.worktreeChangedFiles).mockResolvedValueOnce([
- "tests/safe-outputs/canary.md",
+ ".smoke/pipeline.yml",
"src/main.rs", // unexpected — must abort before any commit/push
]);
@@ -262,35 +343,32 @@ describe("compiler-smoke-e2e index.main (unexpected path guard)", () => {
expect(mockCalls).not.toContain("commitAll");
expect(mockCalls).not.toContain("pushCandidate");
- expect(mockCalls).not.toContain("deleteRemoteRef");
+ expect(mockCalls).not.toContain("deleteRemoteRefs");
expect(mockCalls).toContain("removeWorktree");
});
});
-describe("compiler-smoke-e2e index.main (stageFixtures reads from the worktree, not BUILD_SOURCESDIRECTORY)", () => {
- it("reads every fixture markdown source from the detached worktree — never from BUILD_SOURCESDIRECTORY (which may sit at a different commit when verifyLocalCommit falls back to the object-existence check)", async () => {
+describe("smoke-e2e index.main (sources read from the worktree, not BUILD_SOURCESDIRECTORY)", () => {
+ it("reads every case source from the detached worktree", async () => {
process.env = { ...process.env, ...baseEnv, VITEST: "true" };
const { main } = await import("../index.js");
const fsModule = await import("node:fs/promises");
- const code = await main();
- expect(code).toBe(0);
+ expect(await main()).toBe(0);
- const mdReadPaths = vi
+ const readPaths = vi
.mocked(fsModule.readFile)
.mock.calls.map((call) => String(call[0]))
- .filter((p) => p.endsWith(".md"));
- expect(mdReadPaths.length).toBeGreaterThan(0);
- for (const p of mdReadPaths) {
- // The worktree lives under the mocked mkdtemp() result, never under
- // BUILD_SOURCESDIRECTORY ("C:\repo").
- expect(p.startsWith("C:\\tmp\\compiler-smoke-xyz")).toBe(true);
+ .filter((p) => p.endsWith(".md") || p.endsWith("cases.json"));
+ expect(readPaths.length).toBeGreaterThan(0);
+ for (const p of readPaths) {
+ expect(p.startsWith(WORKTREE)).toBe(true);
expect(p.startsWith("C:\\repo")).toBe(false);
}
});
});
-describe("compiler-smoke-e2e index.main (PR base-ref regression — Fix #1)", () => {
- it("never fetches BUILD_SOURCEBRANCH from the mirror for a GitHub PR build; the worktree is based on the local BUILD_SOURCEVERSION", async () => {
+describe("smoke-e2e index.main (PR base-ref regression)", () => {
+ it("never fetches BUILD_SOURCEBRANCH from the mirror for a GitHub PR build", async () => {
process.env = {
...process.env,
...baseEnv,
@@ -300,79 +378,89 @@ describe("compiler-smoke-e2e index.main (PR base-ref regression — Fix #1)", ()
};
const { main } = await import("../index.js");
const gitModule = await import("../git.js");
- const code = await main();
- expect(code).toBe(0);
+ expect(await main()).toBe(0);
- // verifyLocalCommit must be asked to verify the LOCAL BUILD_SOURCEVERSION
- // — never the PR ref.
- expect(
- vi.mocked(gitModule.verifyLocalCommit).mock.calls[0]?.[0],
- ).toMatchObject({
+ expect(vi.mocked(gitModule.verifyLocalCommit).mock.calls[0]?.[0]).toMatchObject({
cwd: "C:\\repo",
expectedSha: "pr-head-sha",
});
- // The worktree is created directly from that same local commit; it must
- // never receive `refs/pull/123/merge` as the commitish.
- const worktreeArgs = vi.mocked(gitModule.createDetachedWorktree).mock
- .calls[0]?.[0] as { commitish?: string } | undefined;
+ const worktreeArgs = vi.mocked(gitModule.createDetachedWorktree).mock.calls[0]?.[0] as
+ | { commitish?: string }
+ | undefined;
expect(worktreeArgs?.commitish).toBe("pr-head-sha");
expect(worktreeArgs?.commitish).not.toBe("refs/pull/123/merge");
});
});
-describe("compiler-smoke-e2e index.main (unproven-terminal ref retention — Fix #3)", () => {
- it("retains (does not delete) the candidate ref when runFixtures cannot prove every build reached a terminal state", async () => {
+describe("smoke-e2e index.main (per-case ref retention)", () => {
+ it("retains only the unproven case's ref and still deletes the proven ones", async () => {
const runnerModule = await import("../runner.js");
- vi.mocked(runnerModule.runFixtures).mockResolvedValueOnce({
- ok: false,
- allTerminal: false,
- results: [
- {
- name: "canary",
- definitionId: 3001,
- buildId: 1,
- url: "https://example/1",
- status: "failed",
- message: "getBuild kept failing",
+ vi.mocked(runnerModule.runFixtures).mockImplementationOnce(
+ async (_client: unknown, requests: readonly { caseId: string; lane: string; definitionId: number }[]) => ({
+ ok: false,
+ allTerminal: false,
+ results: requests.map((r, i) => ({
+ caseId: r.caseId,
+ lane: r.lane,
+ definitionId: r.definitionId,
+ buildId: i + 1,
+ url: `https://example/${i + 1}`,
+ status: (i === 1 ? "failed" : "succeeded") as "failed" | "succeeded",
+ message: i === 1 ? "getBuild kept failing" : undefined,
durationMs: 1,
- terminalProven: false,
- },
- ],
- });
+ // Only the second case could not be proven terminal.
+ terminalProven: i !== 1,
+ })),
+ }),
+ );
process.env = { ...process.env, ...baseEnv, VITEST: "true" };
const { main } = await import("../index.js");
- const code = await main();
- expect(code).toBe(1);
+ expect(await main()).toBe(1);
- // The push itself succeeded, but the ref must be RETAINED, not deleted,
- // because this run could not prove the build actually stopped.
expect(mockCalls).toContain("pushCandidate");
- expect(mockCalls).not.toContain("deleteRemoteRef");
- expect(mockCalls).toContain("removeWorktree");
+ // All but one ref is provably safe to delete; the unproven one is kept
+ // for the stale-ref scanner. Under the old shared-ref model, one unproven
+ // build stranded every case's ref.
+ expect(deletedRefs).toEqual([
+ "refs/heads/ado-aw-smoke-candidate/630001/canary",
+ "refs/heads/ado-aw-smoke-candidate/630001/noop-target",
+ "refs/heads/ado-aw-smoke-candidate/630001/custom-safe-output",
+ "refs/heads/ado-aw-smoke-candidate/630001/multi-repo",
+ ]);
+ expect(deletedRefs).not.toContain("refs/heads/ado-aw-smoke-candidate/630001/azure-cli");
});
- it("retains (does not delete) the candidate ref when runFixtures itself throws unexpectedly (never trusts the fail-closed default's absence of proof)", async () => {
+ it("retains every pushed ref when runFixtures throws, because builds may already be queued", async () => {
+ // Fail-closed regression: a throw out of runFixtures leaves no results at
+ // all, which must NOT be mistaken for "nothing was queued". Deleting here
+ // would pull refs out from under builds that may still be running.
const runnerModule = await import("../runner.js");
- vi.mocked(runnerModule.runFixtures).mockImplementationOnce(async () => {
- mockCalls.push("runFixtures");
- throw new Error(
- "runner crashed after queueing an unknown number of builds",
- );
- });
+ vi.mocked(runnerModule.runFixtures).mockRejectedValueOnce(new Error("runner exploded"));
process.env = { ...process.env, ...baseEnv, VITEST: "true" };
const { main } = await import("../index.js");
- const code = await main();
- expect(code).toBe(1);
+ expect(await main()).toBe(1);
- // The push succeeded and runFixtures was entered, but because it threw
- // instead of returning a proven outcome, `allChildrenTerminal` must
- // still be `false` (its pre-call fail-closed value) — the ref must be
- // retained, never deleted.
expect(mockCalls).toContain("pushCandidate");
- expect(mockCalls).toContain("runFixtures");
- expect(mockCalls).not.toContain("deleteRemoteRef");
+ expect(deletedRefs).toEqual([]);
expect(mockCalls).toContain("removeWorktree");
});
+
+ it("still deletes pushed refs when staging fails before any build is queued", async () => {
+ // The mirror image: if we never reached runFixtures, no build can exist,
+ // so retaining refs would just leak them.
+ const gitModule = await import("../git.js");
+ vi.mocked(gitModule.worktreeChangedFiles)
+ .mockResolvedValueOnce([".smoke/pipeline.yml"])
+ .mockResolvedValueOnce([".smoke/pipeline.yml", "src/main.rs"]);
+
+ process.env = { ...process.env, ...baseEnv, VITEST: "true" };
+ const { main } = await import("../index.js");
+ expect(await main()).toBe(1);
+
+ expect(mockCalls).not.toContain("runFixtures");
+ // The first case was pushed before the second tripped the allowlist guard.
+ expect(deletedRefs).toEqual(["refs/heads/ado-aw-smoke-candidate/630001/canary"]);
+ });
});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/pipeline-policy.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/pipeline-policy.test.ts
index 1e49ae76..6a02f99a 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/pipeline-policy.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/pipeline-policy.test.ts
@@ -5,35 +5,85 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
-const pipelinePath = resolve(
- dirname(fileURLToPath(import.meta.url)),
- "../../../../../tests/compiler-smoke-e2e/azure-pipelines.yml",
-);
-
-describe("candidate compiler trigger policy", () => {
- const text = readFileSync(pipelinePath, "utf8");
- const pipeline = parse(text) as {
+const smokeDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../tests/smoke");
+const pipelinePath = resolve(smokeDir, "azure-pipelines-candidate.yml");
+const releasePath = resolve(smokeDir, "azure-pipelines-release.yml");
+const stepsPath = resolve(smokeDir, "orchestrator-steps.yml");
+
+interface StepLike {
+ condition?: string;
+ displayName?: string;
+ inputs?: { artifact?: string; targetPath?: string };
+ script?: string;
+ task?: string;
+ env?: Record;
+}
+
+/**
+ * Flatten the shared steps template, descending into `${{ if … }}:`
+ * conditional blocks (which parse as a single-key map whose value is a list of
+ * steps) so mode-gated steps are still reachable.
+ */
+function collectSteps(node: unknown, out: StepLike[]): void {
+ if (Array.isArray(node)) {
+ for (const item of node) collectSteps(item, out);
+ return;
+ }
+ if (!node || typeof node !== "object") return;
+ const obj = node as Record;
+ if (typeof obj.displayName === "string" || typeof obj.task === "string") {
+ out.push(obj as StepLike);
+ return;
+ }
+ for (const value of Object.values(obj)) collectSteps(value, out);
+}
+
+describe("candidate orchestrator trigger policy", () => {
+ const text = readFileSync(stepsPath, "utf8");
+ const pipeline = parse(readFileSync(pipelinePath, "utf8")) as {
trigger?: string;
- pr?: { branches?: { include?: string[] } };
+ pr?: { branches?: { include?: string[] }; paths?: { include?: string[] } };
schedules?: Array<{
cron?: string;
branches?: { include?: string[] };
always?: boolean;
}>;
jobs?: Array<{
- steps?: Array<{
- condition?: string;
- displayName?: string;
- inputs?: {
- artifact?: string;
- targetPath?: string;
- };
- script?: string;
- task?: string;
- }>;
+ steps?: Array<{ template?: string; parameters?: { compilerSource?: string } }>;
}>;
};
- const steps = pipeline.jobs?.flatMap((job) => job.steps ?? []) ?? [];
+ const steps: StepLike[] = [];
+ collectSteps((parse(text) as { steps?: unknown }).steps, steps);
+
+ it("path-filters on the relocated smoke directory", () => {
+ expect(pipeline.pr?.paths?.include).toContain("tests/smoke/**");
+ expect(pipeline.pr?.paths?.include).toContain("tests/safe-outputs/**");
+ expect(pipeline.pr?.paths?.include).not.toContain("tests/compiler-smoke-e2e/**");
+ });
+
+ it("passes candidate mode to the shared steps template", () => {
+ const template = pipeline.jobs?.[0]?.steps?.[0];
+ expect(template?.template).toBe("orchestrator-steps.yml");
+ expect(template?.parameters?.compilerSource).toBe("candidate");
+ });
+
+ it("builds Rust only in candidate mode and downloads a release only in released mode", () => {
+ expect(text).toContain("${{ if eq(parameters.compilerSource, 'candidate') }}");
+ expect(text).toContain("${{ if eq(parameters.compilerSource, 'released') }}");
+ const download = steps.find((step) => step.displayName === "Download latest released ado-aw");
+ expect(download?.script).toContain("releases/latest");
+ expect(download?.script).toContain("ado-aw-linux-x64");
+ });
+
+ it("passes the compiler source and exactly one definition id per lane to the harness", () => {
+ const run = steps.find((step) => step.displayName?.startsWith("Run all smoke cases"));
+ const env = run?.env ?? {};
+ expect(env.SMOKE_COMPILER_SOURCE).toBe("${{ parameters.compilerSource }}");
+ expect(Object.keys(env).filter((key) => key.endsWith("_DEFINITION_ID")).sort()).toEqual([
+ "SMOKE_LANE_AGENTIC_DEFINITION_ID",
+ "SMOKE_LANE_INFRA_DEFINITION_ID",
+ ]);
+ });
it("keeps PRs eligible for the Azure Pipelines comment trigger", () => {
expect(pipeline.trigger).toBe("none");
@@ -65,11 +115,11 @@ describe("candidate compiler trigger policy", () => {
it("preserves bounded ADO response diagnostics when the policy audit fails", () => {
const initialize = steps.find(
(step) =>
- step.displayName === "Initialize candidate smoke diagnostics",
+ step.displayName === "Initialize smoke diagnostics",
);
expect(initialize?.script).toContain('mkdir -p "$DIAGNOSTICS"');
expect(initialize?.script).toContain(
- '"ado-aw/candidate-smoke-diagnostics/1"',
+ '"ado-aw/smoke-diagnostics/1"',
);
const audit = steps.find(
@@ -79,7 +129,7 @@ describe("candidate compiler trigger policy", () => {
expect(audit?.script).toContain("--fail-with-body");
expect(audit?.script).toContain('--dump-header "$raw_headers"');
expect(audit?.script).toContain(
- 'RAW_DIAGNOSTICS="$(Agent.TempDirectory)/compiler-smoke-policy"',
+ 'RAW_DIAGNOSTICS="$(Agent.TempDirectory)/smoke-policy"',
);
expect(audit?.script).toContain(
'body="$RAW_DIAGNOSTICS/definition-${id}-attempt-${attempt}.body"',
@@ -101,16 +151,46 @@ describe("candidate compiler trigger policy", () => {
expect(audit?.script).toContain("response_sample_begin");
const publish = steps.find(
- (step) => step.displayName === "Publish candidate smoke diagnostics",
+ (step) => step.displayName === "Publish smoke diagnostics",
);
expect(publish).toMatchObject({
condition: "always()",
inputs: {
- artifact: "compiler-smoke-diagnostics",
+ artifact: "smoke-diagnostics",
targetPath:
- "$(Build.ArtifactStagingDirectory)/compiler-smoke-diagnostics",
+ "$(Build.ArtifactStagingDirectory)/smoke-diagnostics",
},
task: "PublishPipelineArtifact@1",
});
});
});
+
+describe("released orchestrator trigger policy", () => {
+ const release = parse(readFileSync(releasePath, "utf8")) as {
+ trigger?: string;
+ pr?: string;
+ schedules?: Array<{ cron?: string; branches?: { include?: string[] }; always?: boolean }>;
+ jobs?: Array<{ steps?: Array<{ template?: string; parameters?: { compilerSource?: string } }> }>;
+ };
+
+ it("is scheduled/manual only - never CI or PR triggered", () => {
+ expect(release.trigger).toBe("none");
+ expect(release.pr).toBe("none");
+ });
+
+ it("runs daily on main", () => {
+ expect(release.schedules).toEqual([
+ expect.objectContaining({
+ cron: "0 3 * * *",
+ branches: { include: ["main"] },
+ always: true,
+ }),
+ ]);
+ });
+
+ it("passes released mode to the shared steps template", () => {
+ const template = release.jobs?.[0]?.steps?.[0];
+ expect(template?.template).toBe("orchestrator-steps.yml");
+ expect(template?.parameters?.compilerSource).toBe("released");
+ });
+});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/report.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/report.test.ts
index 97f8a301..ad8ecd71 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/report.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/report.test.ts
@@ -5,7 +5,7 @@ import { renderResultsTable } from "../report.js";
function result(overrides: Partial): FixtureBuildResult {
return {
- name: "canary",
+ caseId: "canary", lane: "agentic",
definitionId: 2601,
status: "succeeded",
durationMs: 12_345,
@@ -15,13 +15,14 @@ function result(overrides: Partial): FixtureBuildResult {
}
describe("renderResultsTable", () => {
- it("renders a header row and one row per fixture", () => {
+ it("renders a header row and one row per case", () => {
const table = renderResultsTable([
- result({ name: "canary", buildId: 1, url: "https://x/1", result: "succeeded" }),
- result({ name: "azure-cli", definitionId: 2602, buildId: 2, url: "https://x/2", result: "succeeded" }),
+ result({ caseId: "canary", lane: "agentic", buildId: 1, url: "https://x/1", result: "succeeded" }),
+ result({ caseId: "azure-cli", lane: "agentic", definitionId: 2602, buildId: 2, url: "https://x/2", result: "succeeded" }),
]);
const lines = table.split("\n");
- expect(lines[0]).toMatch(/fixture/);
+ expect(lines[0]).toMatch(/case/);
+ expect(lines[0]).toMatch(/lane/);
expect(lines[0]).toMatch(/definition/);
expect(lines[0]).toMatch(/result/);
expect(table).toContain("canary");
@@ -32,13 +33,13 @@ describe("renderResultsTable", () => {
it("preserves the caller's declaration order", () => {
const table = renderResultsTable([
- result({ name: "smoke-failure-reporter", definitionId: 2604 }),
- result({ name: "canary", definitionId: 2601 }),
+ result({ caseId: "multi-repo", lane: "agentic", definitionId: 2604 }),
+ result({ caseId: "canary", lane: "agentic", definitionId: 2601 }),
]);
- const reporterIdx = table.indexOf("smoke-failure-reporter");
+ const multiRepoIdx = table.indexOf("multi-repo");
const canaryIdx = table.indexOf("canary");
- expect(reporterIdx).toBeGreaterThan(-1);
- expect(canaryIdx).toBeGreaterThan(reporterIdx);
+ expect(multiRepoIdx).toBeGreaterThan(-1);
+ expect(canaryIdx).toBeGreaterThan(multiRepoIdx);
});
it("renders a '-' placeholder for missing buildId/url", () => {
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/runner.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/runner.test.ts
index c9164fd7..575119e0 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/runner.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/runner.test.ts
@@ -62,6 +62,7 @@ function makeFakeClient(opts: {
cancelled.push(buildId);
opts.onCancel?.(buildId);
},
+ async addBuildTags() {},
buildUrl(buildId) {
return `https://example/_build/results?buildId=${buildId}`;
},
@@ -69,8 +70,8 @@ function makeFakeClient(opts: {
return { client, cancelled };
}
-function req(name: FixtureBuildRequest["name"], definitionId: number): FixtureBuildRequest {
- return { name, definitionId, sourceBranch: "refs/heads/x", sourceVersion: "sha" };
+function req(caseId: string, definitionId: number): FixtureBuildRequest {
+ return { caseId, lane: "agentic", definitionId, sourceBranch: "refs/heads/x", sourceVersion: "sha" };
}
const noopSleep = async (): Promise => {};
@@ -120,7 +121,7 @@ describe("runFixtures", () => {
log: () => {},
sleepImpl: noopSleep,
});
- expect(outcome.results.map((r) => r.name)).toEqual(["canary", "azure-cli"]);
+ expect(outcome.results.map((r) => r.caseId)).toEqual(["canary", "azure-cli"]);
expect(outcome.results.every((r) => r.status === "succeeded")).toBe(true);
});
@@ -142,8 +143,8 @@ describe("runFixtures", () => {
sleepImpl: noopSleep,
});
expect(outcome.ok).toBe(false);
- const canary = outcome.results.find((r) => r.name === "canary")!;
- const azureCli = outcome.results.find((r) => r.name === "azure-cli")!;
+ const canary = outcome.results.find((r) => r.caseId === "canary")!;
+ const azureCli = outcome.results.find((r) => r.caseId === "azure-cli")!;
expect(canary.status).toBe("queue-failed");
expect(canary.message).toMatch(/definition disabled/);
expect(azureCli.status).toBe("succeeded");
@@ -174,8 +175,8 @@ describe("runFixtures", () => {
cancelGraceMs: 5,
});
expect(outcome.ok).toBe(false);
- const canary = outcome.results.find((r) => r.name === "canary")!;
- const azureCli = outcome.results.find((r) => r.name === "azure-cli")!;
+ const canary = outcome.results.find((r) => r.caseId === "canary")!;
+ const azureCli = outcome.results.find((r) => r.caseId === "azure-cli")!;
expect(canary.status).toBe("failed");
expect(azureCli.status === "canceled" || azureCli.status === "timed-out").toBe(true);
expect(cancelled).toContain(402);
@@ -222,7 +223,7 @@ describe("runFixtures", () => {
sleepImpl: noopSleep,
cancelGraceMs: 5,
});
- const azureCli = outcome.results.find((r) => r.name === "azure-cli")!;
+ const azureCli = outcome.results.find((r) => r.caseId === "azure-cli")!;
expect(azureCli.status).toBe("timed-out");
expect(azureCli.message).toMatch(/cancellation grace period/);
});
@@ -234,7 +235,7 @@ describe("runFixtures", () => {
"canary",
"azure-cli",
"noop-target",
- "smoke-failure-reporter",
+ "multi-repo",
] as const;
const buildIds = [701, 702, 703, 704];
@@ -250,6 +251,7 @@ describe("runFixtures", () => {
return { status: "completed", result: "succeeded" };
},
async cancelBuild() {},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
@@ -274,6 +276,7 @@ describe("runFixtures", () => {
throw new Error("transient network error");
},
async cancelBuild() {},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(client, [req("canary", 1)], {
@@ -309,6 +312,7 @@ describe("runFixtures", () => {
};
},
async cancelBuild() {},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(client, [req("canary", 1)], {
@@ -336,6 +340,7 @@ describe("runFixtures", () => {
async cancelBuild() {
throw new Error("cancel API rejected");
},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(client, [req("canary", 1)], {
@@ -385,6 +390,7 @@ describe("runFixtures", () => {
async cancelBuild(buildId) {
cancelled.push(buildId);
},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(client, [req("canary", 1), req("azure-cli", 2)], {
@@ -396,7 +402,7 @@ describe("runFixtures", () => {
cancelGraceMs: 5,
});
expect(outcome.ok).toBe(false);
- const canary = outcome.results.find((r) => r.name === "canary")!;
+ const canary = outcome.results.find((r) => r.caseId === "canary")!;
expect(canary.status).toBe("failed");
expect(canary.terminalProven).toBe(true);
expect(canary.message).toMatch(/does not match the requested queue parameters/);
@@ -420,6 +426,7 @@ describe("runFixtures", () => {
};
},
async cancelBuild() {},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(client, [req("canary", 1)], {
@@ -451,6 +458,7 @@ describe("runFixtures", () => {
};
},
async cancelBuild() {},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(client, [req("canary", 1)], {
@@ -477,6 +485,7 @@ describe("runFixtures", () => {
return { status: "completed", result: "succeeded" };
},
async cancelBuild() {},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(client, [req("canary", 1)], {
@@ -533,6 +542,7 @@ describe("runFixtures", () => {
async cancelBuild(buildId) {
cancelled.push(buildId);
},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(client, [req("canary", 1), req("azure-cli", 2)], {
@@ -545,10 +555,10 @@ describe("runFixtures", () => {
});
expect(outcome.ok).toBe(false);
expect(cancelled).toContain(902);
- const azureCli = outcome.results.find((r) => r.name === "azure-cli")!;
+ const azureCli = outcome.results.find((r) => r.caseId === "azure-cli")!;
expect(azureCli.status).toBe("canceled");
expect(azureCli.terminalProven).toBe(true);
- const canary = outcome.results.find((r) => r.name === "canary")!;
+ const canary = outcome.results.find((r) => r.caseId === "canary")!;
expect(canary.status).toBe("queue-failed");
// Ambiguous queue failure: never proven, so the overall run can't
// claim every build is terminal even though the sibling was cancelled
@@ -585,6 +595,7 @@ describe("runFixtures", () => {
};
},
async cancelBuild() {},
+ addBuildTags: async () => {},
buildUrl: (id) => `https://example/${id}`,
};
const outcome = await runFixtures(
@@ -593,7 +604,7 @@ describe("runFixtures", () => {
{ concurrency: 3, timeoutMs: 10_000, pollMs: 1, log: () => {} },
);
expect(resolveOrder.indexOf(1)).toBe(2); // definition 1 resolves LAST despite being declared first
- expect(outcome.results.map((r) => r.name)).toEqual(["canary", "azure-cli", "noop-target"]);
+ expect(outcome.results.map((r) => r.caseId)).toEqual(["canary", "azure-cli", "noop-target"]);
expect(outcome.results.every((r) => r.status === "succeeded")).toBe(true);
});
});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/signals.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/signals.test.ts
index b6b152e6..1e7aa44e 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/signals.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/signals.test.ts
@@ -1,13 +1,37 @@
import { describe, expect, it } from "vitest";
+import type { ResolvedCase } from "../cases.js";
import type { FixtureBuildResult } from "../runner.js";
-import { verifyFixtureSignals } from "../signals.js";
+import { verifyCaseSignals } from "../signals.js";
-function result(
- overrides: Partial = {},
-): FixtureBuildResult {
+/**
+ * Tag requirements are declared per case in the manifest, not hardcoded in
+ * `signals.ts` — `custom-safe-output` declares one, `canary` declares none.
+ */
+const CASES: ResolvedCase[] = [
+ {
+ id: "custom-safe-output",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate"],
+ source: "tests/smoke/custom-safe-output.md",
+ assertions: { requiredBuildTags: ["ado-aw-custom-job-{buildId}"] },
+ definitionId: 3006,
+ },
+ {
+ id: "canary",
+ lane: "agentic",
+ kind: "compiled",
+ modes: ["candidate"],
+ source: "tests/safe-outputs/canary.md",
+ definitionId: 3006,
+ },
+];
+
+function result(overrides: Partial = {}): FixtureBuildResult {
return {
- name: "custom-safe-output",
+ caseId: "custom-safe-output",
+ lane: "agentic",
definitionId: 3006,
buildId: 42,
url: "https://example/42",
@@ -19,21 +43,21 @@ function result(
};
}
-describe("verifyFixtureSignals", () => {
- it("passes when the custom job tag exists", async () => {
- const outcome = await verifyFixtureSignals(
- {
- getBuildTags: async () => ["unrelated", "ado-aw-custom-job-42"],
- },
+describe("verifyCaseSignals", () => {
+ it("expands {buildId} and passes when the custom job tag exists", async () => {
+ const outcome = await verifyCaseSignals(
+ { getBuildTags: async () => ["unrelated", "ado-aw-custom-job-42"] },
+ CASES,
[result()],
);
expect(outcome.ok).toBe(true);
expect(outcome.results[0]?.status).toBe("succeeded");
});
- it("fails a successful child when the custom job tag is missing", async () => {
- const outcome = await verifyFixtureSignals(
+ it("fails a successful child when the declared tag is missing", async () => {
+ const outcome = await verifyCaseSignals(
{ getBuildTags: async () => ["unrelated"] },
+ CASES,
[result()],
);
expect(outcome.ok).toBe(false);
@@ -46,12 +70,13 @@ describe("verifyFixtureSignals", () => {
});
it("reports tag API failures without losing terminal proof", async () => {
- const outcome = await verifyFixtureSignals(
+ const outcome = await verifyCaseSignals(
{
getBuildTags: async () => {
throw new Error("tag API unavailable");
},
},
+ CASES,
[result()],
);
expect(outcome.ok).toBe(false);
@@ -61,32 +86,51 @@ describe("verifyFixtureSignals", () => {
it("does not query tags for a child that already failed", async () => {
let calls = 0;
- const outcome = await verifyFixtureSignals(
+ const outcome = await verifyCaseSignals(
{
getBuildTags: async () => {
calls++;
return [];
},
},
+ CASES,
[result({ status: "failed", result: "failed" })],
);
expect(calls).toBe(0);
expect(outcome.ok).toBe(false);
});
- it("leaves fixtures without signal requirements unchanged", async () => {
+ it("leaves cases without declared tag assertions unchanged", async () => {
let calls = 0;
- const canary = result({ name: "canary" });
- const outcome = await verifyFixtureSignals(
+ const canary = result({ caseId: "canary" });
+ const outcome = await verifyCaseSignals(
{
getBuildTags: async () => {
calls++;
return [];
},
},
+ CASES,
[canary],
);
expect(calls).toBe(0);
expect(outcome).toEqual({ ok: true, results: [canary] });
});
+
+ it("ignores a result whose case is not in the manifest", async () => {
+ let calls = 0;
+ const orphan = result({ caseId: "not-declared" });
+ const outcome = await verifyCaseSignals(
+ {
+ getBuildTags: async () => {
+ calls++;
+ return [];
+ },
+ },
+ CASES,
+ [orphan],
+ );
+ expect(calls).toBe(0);
+ expect(outcome.ok).toBe(true);
+ });
});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/source.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/source.test.ts
index 9a14b6f1..b053e326 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/source.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/source.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { injectPipelineArtifact, splitFrontMatter } from "../source.js";
+import { prepareCaseSource, splitFrontMatter } from "../source.js";
const VALUES = {
project: "AgentPlayground",
@@ -29,10 +29,10 @@ describe("splitFrontMatter", () => {
});
});
-describe("injectPipelineArtifact", () => {
+describe("prepareCaseSource", () => {
it("injects literal project/definition-id/run-id/artifact under supply-chain.pipeline-artifact", () => {
const md = "---\nname: canary\non:\n schedule:\n - cron: '0 3 * * *'\n---\nBody unchanged.\n";
- const out = injectPipelineArtifact(md, VALUES);
+ const out = prepareCaseSource(md, VALUES);
const { yamlText, body } = splitFrontMatter(out);
expect(yamlText).toContain("supply-chain:");
expect(yamlText).toContain("pipeline-artifact:");
@@ -46,29 +46,29 @@ describe("injectPipelineArtifact", () => {
it("preserves the markdown body byte-for-byte, including trailing whitespace quirks", () => {
const body = "# Title\n\n- a\n- b\n\ntrailing spaces \n\n\nextra blank lines\n";
const md = `---\nname: x\n---\n${body}`;
- const out = injectPipelineArtifact(md, VALUES);
+ const out = prepareCaseSource(md, VALUES);
expect(out.endsWith(body)).toBe(true);
});
- it("removes on.schedule but preserves other 'on' keys", () => {
+ it("removes the whole 'on' block, not just on.schedule", () => {
const md = "---\nname: x\non:\n schedule:\n - cron: '0 3 * * *'\n workflow_dispatch: {}\n---\nBody.\n";
- const out = injectPipelineArtifact(md, VALUES);
+ const out = prepareCaseSource(md, VALUES);
const { yamlText } = splitFrontMatter(out);
expect(yamlText).not.toContain("schedule");
- expect(yamlText).toContain("workflow_dispatch");
- expect(yamlText).toContain("on:");
+ expect(yamlText).not.toContain("workflow_dispatch");
+ expect(yamlText).not.toContain("on:");
});
it("removes the 'on' key entirely once schedule was its only member", () => {
const md = "---\nname: x\non:\n schedule:\n - cron: '0 3 * * *'\n---\nBody.\n";
- const out = injectPipelineArtifact(md, VALUES);
+ const out = prepareCaseSource(md, VALUES);
const { yamlText } = splitFrontMatter(out);
expect(yamlText).not.toMatch(/^on:/m);
});
it("is a no-op with respect to 'on' when there is no 'on' block at all", () => {
const md = "---\nname: noop-target\n---\nBody.\n";
- const out = injectPipelineArtifact(md, VALUES);
+ const out = prepareCaseSource(md, VALUES);
const { yamlText } = splitFrontMatter(out);
expect(yamlText).not.toMatch(/^on:/m);
expect(yamlText).toContain("supply-chain:");
@@ -77,7 +77,7 @@ describe("injectPipelineArtifact", () => {
it("preserves an existing supply-chain.registry untouched", () => {
const md =
"---\nname: x\nsupply-chain:\n registry: my-registry\n---\nBody.\n";
- const out = injectPipelineArtifact(md, VALUES);
+ const out = prepareCaseSource(md, VALUES);
const { yamlText } = splitFrontMatter(out);
expect(yamlText).toContain("registry: my-registry");
expect(yamlText).toContain("pipeline-artifact:");
@@ -85,17 +85,39 @@ describe("injectPipelineArtifact", () => {
it("rejects a fixture that already defines supply-chain.feed", () => {
const md = "---\nname: x\nsupply-chain:\n feed: my-feed\n---\nBody.\n";
- expect(() => injectPipelineArtifact(md, VALUES)).toThrow(/supply-chain\.feed/);
+ expect(() => prepareCaseSource(md, VALUES)).toThrow(/supply-chain\.feed/);
});
it("rejects a fixture that already defines supply-chain.pipeline-artifact", () => {
const md =
"---\nname: x\nsupply-chain:\n pipeline-artifact:\n project: Other\n definition-id: 1\n run-id: 1\n artifact: a\n---\nBody.\n";
- expect(() => injectPipelineArtifact(md, VALUES)).toThrow(/pipeline-artifact/);
+ expect(() => prepareCaseSource(md, VALUES)).toThrow(/pipeline-artifact/);
});
it("throws on malformed YAML front matter", () => {
const md = "---\nname: [unterminated\n---\nBody.\n";
- expect(() => injectPipelineArtifact(md, VALUES)).toThrow();
+ expect(() => prepareCaseSource(md, VALUES)).toThrow();
+ });
+});
+
+describe("prepareCaseSource + assertNoTriggers", () => {
+ // `on:` is the complete declaration of when a pipeline runs, so stripping it
+ // makes the compiler emit an explicit `trigger: none` / `pr: none`. Since all
+ // cases in a lane share one definition AND one YAML path, anything else would
+ // let a ref push queue the lane on top of the API-queued run.
+ it("accepts the manual-only shape the compiler emits for an `on:`-less source", async () => {
+ const { assertNoTriggers } = await import("../assertions.js");
+ expect(() =>
+ assertNoTriggers("trigger: none\npr: none\njobs: []\n", "case"),
+ ).not.toThrow();
+ });
+
+ it("fails closed when a compiler omits the trigger keys entirely", async () => {
+ // Pre-#1786 compilers emitted no `trigger:` key, and ADO reads a missing
+ // `trigger:` as "CI on every branch" rather than "no CI".
+ const { assertNoTriggers } = await import("../assertions.js");
+ expect(() => assertNoTriggers("jobs:\n - job: Agent\n", "case")).toThrow(
+ /must declare 'trigger: none'/,
+ );
});
});
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/stale.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/stale.test.ts
index c9ec6969..4eae96f7 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/stale.test.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/stale.test.ts
@@ -8,7 +8,7 @@ const HOUR = 3_600_000;
const CHILD_DEFINITION_IDS = [901, 902, 903];
function ref(buildId: number): RemoteRef {
- return { ref: `refs/heads/ado-aw-smoke-candidate/${buildId}`, sha: `sha-${buildId}` };
+ return { ref: `refs/heads/ado-aw-smoke-candidate/${buildId}/canary`, sha: `sha-${buildId}` };
}
interface ClientOpts {
@@ -36,9 +36,9 @@ function client(builds: Record, opts: ClientOpts = {}):
const baseOpts = {
baseRef: "refs/heads/main",
- ownRef: "refs/heads/ado-aw-smoke-candidate/999",
+ ownRef: "refs/heads/ado-aw-smoke-candidate/999/canary",
definitionId: 42,
- childDefinitionIds: CHILD_DEFINITION_IDS,
+ laneDefinitionIds: CHILD_DEFINITION_IDS,
staleRefHours: 24,
};
@@ -137,7 +137,7 @@ describe("scanStaleRefs", () => {
...baseOpts,
refs: [
{ ref: "refs/heads/main", sha: "base" },
- { ref: "refs/heads/ado-aw-smoke-candidate/999", sha: "own" },
+ { ref: "refs/heads/ado-aw-smoke-candidate/999/canary", sha: "own" },
],
client: client({}),
now: () => NOW,
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts b/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts
index 1812eba1..041eee4d 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts
@@ -219,6 +219,22 @@ export class AdoRest {
await this.request(path, { method: "PATCH", body: { status: "cancelling" } });
}
+ /**
+ * Add labelling tags to a queued build.
+ *
+ * Every case in a lane shares one definition, so tags (alongside the
+ * per-case `sourceBranch`) are how a run is identified in the lane's
+ * history. Callers treat failures here as non-fatal.
+ */
+ async addBuildTags(buildId: number, tags: readonly string[]): Promise {
+ for (const tag of tags) {
+ const path = this.projPath(
+ `_apis/build/builds/${buildId}/tags/${AdoRest.seg(tag)}?api-version=7.1`,
+ );
+ await this.request(path, { method: "PUT" });
+ }
+ }
+
/**
* Queue a build of `definitionId`, pointed at the staged candidate branch
* + exact commit. Both are always supplied (never sourceBranch alone) so
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts b/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts
index 8fa3ff0a..794cea37 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts
@@ -1,6 +1,6 @@
/**
- * Post-compile assertions run against each fixture's freshly regenerated
- * `*.lock.yml` inside the detached worktree, before anything is committed or
+ * Post-compile assertions run against each case's freshly regenerated
+ * pipeline YAML inside the detached worktree, before anything is committed or
* pushed.
*
* Test-harness module; not shipped in `ado-script.zip`.
@@ -11,24 +11,86 @@ import { parseAllDocuments } from "yaml";
* Release URLs the candidate lane must never reference — the whole point of
* pinning `supply-chain.pipeline-artifact` is to source every binary
* (compiler, AWF, ado-script) from the current build's own artifact instead
- * of a public release.
+ * of a public release. In `released` mode the polarity is inverted: at least
+ * one of these must be present, otherwise the run silently stopped exercising
+ * release packaging.
*/
-const FORBIDDEN_URL_SNIPPETS = [
+const RELEASE_URL_SNIPPETS = [
"github.com/githubnext/ado-aw/releases",
"github.com/github/gh-aw-firewall/releases",
] as const;
/** Throws if the compiled YAML still references a public release download URL. */
export function assertNoForbiddenReleaseUrls(yamlText: string, label: string): void {
- for (const snippet of FORBIDDEN_URL_SNIPPETS) {
+ for (const snippet of RELEASE_URL_SNIPPETS) {
if (yamlText.includes(snippet)) {
throw new Error(
- `${label}: compiled pipeline still references a release URL ('${snippet}') — the candidate lane must source binaries exclusively from the pinned pipeline artifact`,
+ `${label}: compiled pipeline still references a release URL ('${snippet}') — candidate mode must source binaries exclusively from the pinned pipeline artifact`,
);
}
}
}
+/**
+ * Throws unless the compiled YAML references a public release download URL.
+ *
+ * The mirror image of {@link assertNoForbiddenReleaseUrls}, and the reason
+ * released mode can replace the retired committed lock files: it proves the
+ * staged pipeline will actually fetch released assets at run time.
+ */
+export function assertReleaseUrlsPresent(yamlText: string, label: string): void {
+ if (!RELEASE_URL_SNIPPETS.some((snippet) => yamlText.includes(snippet))) {
+ throw new Error(
+ `${label}: compiled pipeline references no release URL (expected one of ${RELEASE_URL_SNIPPETS.join(", ")}) — released mode must exercise release asset download`,
+ );
+ }
+}
+
+/**
+ * Assert a staged case carries no trigger of any kind.
+ *
+ * Load-bearing under the lane model: every case is staged to the same
+ * `.smoke/pipeline.yml` path against the same lane definition, so a case that
+ * compiled a real `trigger:`/`pr:`/`schedules:` block would cause its ref push
+ * to CI-trigger the lane *in addition to* the API-queued run — double-queueing
+ * the lane and burning parallel jobs.
+ *
+ * Applies to `raw` cases too, where no front-matter transform runs at all.
+ */
+export function assertNoTriggers(yamlText: string, label: string): void {
+ const docs = parseAllDocuments(yamlText, { merge: false }).map((d) => d.toJS());
+ for (const doc of docs) {
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) continue;
+ const root = doc as Record;
+
+ for (const key of ["trigger", "pr"] as const) {
+ if (root[key] !== "none") {
+ throw new Error(
+ `${label}: staged pipeline must declare '${key}: none', got ${JSON.stringify(root[key] ?? null)}`,
+ );
+ }
+ }
+
+ if (root.schedules !== undefined) {
+ throw new Error(
+ `${label}: staged pipeline must not declare 'schedules:' — the orchestrator owns scheduling`,
+ );
+ }
+
+ const resources = root.resources as Record | undefined;
+ const pipelines = resources?.pipelines;
+ if (Array.isArray(pipelines)) {
+ for (const entry of pipelines) {
+ if (entry && typeof entry === "object" && (entry as Record).trigger !== undefined) {
+ throw new Error(
+ `${label}: staged pipeline must not declare a 'resources.pipelines[].trigger'`,
+ );
+ }
+ }
+ }
+ }
+}
+
export interface ExpectedPipelineArtifact {
readonly project: string;
readonly pipeline: string;
@@ -88,10 +150,20 @@ function singleStep(
}
/**
- * Assert the Stage 1 ADO credential boundary in freshly compiled YAML.
+ * Assert the Stage 1 credential boundary in freshly compiled YAML.
*
* Agent and Detection must not receive any ADO credential, regardless of
* whether the workflow configures `permissions.read`.
+ *
+ * `ADO_AW_GITHUB_TOKEN` is included because it is the only credential in the
+ * suite that grants write access OUTSIDE the AgentPlayground project (Issues
+ * write on an external GitHub repo). The compiler already confines it to the
+ * Stage 3 executor env, but nothing else here would catch a regression that
+ * projected it into Stage 1 — which is exactly the reach-outside-ADO escape
+ * the lane split exists to bound.
+ *
+ * Note `GITHUB_TOKEN` is deliberately NOT forbidden: that is Copilot CLI
+ * authentication and the Agent legitimately receives it.
*/
export function assertAdoTokenIsolation(
yamlText: string,
@@ -103,22 +175,20 @@ export function assertAdoTokenIsolation(
const agentEnv = (agent.env ?? {}) as Record;
const detectionEnv = (detection.env ?? {}) as Record;
- for (const forbidden of [
+ const FORBIDDEN = [
"AZURE_DEVOPS_EXT_PAT",
"SC_READ_TOKEN",
"SC_WRITE_TOKEN",
"SYSTEM_ACCESSTOKEN",
- ]) {
+ "ADO_AW_GITHUB_TOKEN",
+ ] as const;
+
+ for (const forbidden of FORBIDDEN) {
if (agentEnv[forbidden] !== undefined) {
throw new Error(`${label}: Agent must not receive ${forbidden}`);
}
}
- for (const forbidden of [
- "AZURE_DEVOPS_EXT_PAT",
- "SC_READ_TOKEN",
- "SC_WRITE_TOKEN",
- "SYSTEM_ACCESSTOKEN",
- ]) {
+ for (const forbidden of FORBIDDEN) {
if (detectionEnv[forbidden] !== undefined) {
throw new Error(`${label}: Detection must not receive ${forbidden}`);
}
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/cases.ts b/scripts/ado-script/src/compiler-smoke-e2e/cases.ts
new file mode 100644
index 00000000..f2198188
--- /dev/null
+++ b/scripts/ado-script/src/compiler-smoke-e2e/cases.ts
@@ -0,0 +1,362 @@
+/**
+ * Case manifest for the smoke suite.
+ *
+ * The manifest (`tests/smoke/cases.json`) is the single source of truth for
+ * what the smoke lanes run. Each entry names a markdown (or, for
+ * `kind: "raw"`, a hand-written YAML) source, the credential *lane* it runs
+ * in, and which compiler-source *modes* it participates in.
+ *
+ * Adding a smoke is a manifest entry plus a source file — no ADO definition
+ * registration, no orchestrator variable, and no change to this file.
+ *
+ * Parsing is strict and fail-closed. `id` in particular is interpolated into
+ * a git ref name, so it is validated against a tight allowlist before any
+ * git invocation can see it.
+ *
+ * Test-harness module; not shipped in `ado-script.zip`.
+ */
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+
+/** Which compiler the staged pipelines are built with and sourced from. */
+export type CompilerSource = "candidate" | "released";
+
+export const COMPILER_SOURCES: readonly CompilerSource[] = ["candidate", "released"];
+
+/** How a case's `.smoke/pipeline.yml` is produced. */
+export type CaseKind = "compiled" | "raw";
+
+export const CASE_KINDS: readonly CaseKind[] = ["compiled", "raw"];
+
+/** Repo-relative path of the case manifest, read from the candidate worktree. */
+export const CASES_MANIFEST_PATH = "tests/smoke/cases.json";
+
+const SUPPORTED_SCHEMA = "ado-aw/smoke-cases/1";
+
+/**
+ * Case ids become the last segment of a git ref
+ * (`refs/heads/ado-aw-smoke-candidate//`), so the allowlist
+ * is deliberately narrow: lowercase alphanumerics and single hyphens only.
+ * This runs before any `git push` argument is constructed.
+ */
+const CASE_ID_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
+
+/** Lane ids appear only in logs and env var lookups, but are kept equally tight. */
+const LANE_ID_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
+
+/** Env var names must look like env var names before we read them. */
+const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,64}$/;
+
+/** The only placeholder supported inside `assertions.requiredBuildTags`. */
+const BUILD_ID_PLACEHOLDER = "{buildId}";
+
+export interface AgentCommandAssertion {
+ readonly required: readonly string[];
+ readonly forbidden: readonly string[];
+}
+
+export interface CaseAssertions {
+ /** Snippets that must / must not appear in the Agent execution step's bash body. */
+ readonly agentCommand?: AgentCommandAssertion;
+ /** Build tags the child run must carry, with `{buildId}` expanded to the child build id. */
+ readonly requiredBuildTags?: readonly string[];
+}
+
+export interface SmokeLane {
+ readonly id: string;
+ readonly definitionIdEnv: string;
+ readonly description?: string;
+}
+
+export interface SmokeCase {
+ readonly id: string;
+ readonly lane: string;
+ readonly kind: CaseKind;
+ readonly modes: readonly CompilerSource[];
+ /** Repo-relative source path (`.md` for compiled, `.yml`/`.yaml` for raw). */
+ readonly source: string;
+ readonly assertions?: CaseAssertions;
+}
+
+export interface SmokeManifest {
+ /** Fixed repo-relative path every case's pipeline is staged to. */
+ readonly yamlPath: string;
+ readonly lanes: readonly SmokeLane[];
+ readonly cases: readonly SmokeCase[];
+}
+
+export interface ResolvedCase extends SmokeCase {
+ /** The registered ADO definition id of this case's lane. */
+ readonly definitionId: number;
+}
+
+export interface ResolvedCases {
+ readonly yamlPath: string;
+ readonly mode: CompilerSource;
+ /** Cases participating in `mode`, in manifest declaration order. */
+ readonly cases: readonly ResolvedCase[];
+ /** Distinct lane definition ids in play for `mode`. */
+ readonly laneDefinitionIds: readonly number[];
+}
+
+function fail(message: string): never {
+ throw new Error(`${CASES_MANIFEST_PATH}: ${message}`);
+}
+
+function asRecord(value: unknown, what: string): Record {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ fail(`${what} must be an object`);
+ }
+ return value as Record;
+}
+
+function asArray(value: unknown, what: string): unknown[] {
+ if (!Array.isArray(value)) fail(`${what} must be an array`);
+ return value;
+}
+
+function asString(value: unknown, what: string): string {
+ if (typeof value !== "string" || value.length === 0) {
+ fail(`${what} must be a non-empty string`);
+ }
+ return value;
+}
+
+function asStringArray(value: unknown, what: string): string[] {
+ return asArray(value, what).map((entry, i) => asString(entry, `${what}[${i}]`));
+}
+
+/**
+ * Validate a repo-relative source path.
+ *
+ * Rejects absolute paths, backslashes, `.` / `..` segments, and anything that
+ * does not normalise to itself — the value is joined against the worktree root
+ * and handed to the compiler, so path traversal here would escape the
+ * checkout.
+ */
+function validateSourcePath(raw: unknown, caseId: string): string {
+ const value = asString(raw, `case '${caseId}' source`);
+ if (value.startsWith("/") || /^[a-zA-Z]:/.test(value)) {
+ fail(`case '${caseId}' source must be repo-relative (got '${value}')`);
+ }
+ if (value.includes("\\")) {
+ fail(`case '${caseId}' source must use forward slashes (got '${value}')`);
+ }
+ const segments = value.split("/");
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
+ fail(`case '${caseId}' source must not contain empty, '.' or '..' segments (got '${value}')`);
+ }
+ return value;
+}
+
+function validateKindMatchesExtension(kind: CaseKind, source: string, caseId: string): void {
+ const isMarkdown = source.endsWith(".md");
+ const isYaml = source.endsWith(".yml") || source.endsWith(".yaml");
+ if (kind === "compiled" && !isMarkdown) {
+ fail(`case '${caseId}' is kind 'compiled' so its source must end in .md (got '${source}')`);
+ }
+ if (kind === "raw" && !isYaml) {
+ fail(`case '${caseId}' is kind 'raw' so its source must end in .yml or .yaml (got '${source}')`);
+ }
+}
+
+function parseAssertions(raw: unknown, caseId: string): CaseAssertions | undefined {
+ if (raw === undefined) return undefined;
+ const obj = asRecord(raw, `case '${caseId}' assertions`);
+
+ let agentCommand: AgentCommandAssertion | undefined;
+ if (obj.agentCommand !== undefined) {
+ const ac = asRecord(obj.agentCommand, `case '${caseId}' assertions.agentCommand`);
+ agentCommand = {
+ required: asStringArray(ac.required ?? [], `case '${caseId}' assertions.agentCommand.required`),
+ forbidden: asStringArray(ac.forbidden ?? [], `case '${caseId}' assertions.agentCommand.forbidden`),
+ };
+ if (agentCommand.required.length === 0 && agentCommand.forbidden.length === 0) {
+ fail(`case '${caseId}' assertions.agentCommand must declare at least one snippet`);
+ }
+ }
+
+ let requiredBuildTags: string[] | undefined;
+ if (obj.requiredBuildTags !== undefined) {
+ requiredBuildTags = asStringArray(
+ obj.requiredBuildTags,
+ `case '${caseId}' assertions.requiredBuildTags`,
+ );
+ for (const tag of requiredBuildTags) {
+ // Catch typo'd placeholders (e.g. `{buildid}`) rather than silently
+ // asserting a tag that can never match.
+ const unknown = tag.match(/\{[^}]*\}/g)?.filter((token) => token !== BUILD_ID_PLACEHOLDER);
+ if (unknown && unknown.length > 0) {
+ fail(
+ `case '${caseId}' requiredBuildTags '${tag}' uses unsupported placeholder(s) ${unknown.join(", ")}; only ${BUILD_ID_PLACEHOLDER} is supported`,
+ );
+ }
+ }
+ }
+
+ if (agentCommand === undefined && requiredBuildTags === undefined) {
+ fail(`case '${caseId}' assertions must declare agentCommand and/or requiredBuildTags`);
+ }
+ return { agentCommand, requiredBuildTags };
+}
+
+/** Expand `{buildId}` in a declared build tag. */
+export function expandBuildTag(tag: string, buildId: number): string {
+ return tag.split(BUILD_ID_PLACEHOLDER).join(String(buildId));
+}
+
+/** Parse and strictly validate the manifest document. Never touches the environment. */
+export function parseManifest(text: string): SmokeManifest {
+ let doc: unknown;
+ try {
+ doc = JSON.parse(text);
+ } catch (err) {
+ fail(`is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ const root = asRecord(doc, "manifest");
+
+ const schema = asString(root.schema, "schema");
+ if (schema !== SUPPORTED_SCHEMA) {
+ fail(`unsupported schema '${schema}' (expected '${SUPPORTED_SCHEMA}')`);
+ }
+
+ const yamlPath = validateSourcePath(root.yamlPath, "");
+
+ const lanesObj = asRecord(root.lanes, "lanes");
+ const lanes: SmokeLane[] = [];
+ for (const [id, value] of Object.entries(lanesObj)) {
+ if (!LANE_ID_RE.test(id)) {
+ fail(`lane id '${id}' must match ${LANE_ID_RE}`);
+ }
+ const lane = asRecord(value, `lane '${id}'`);
+ const definitionIdEnv = asString(lane.definitionIdEnv, `lane '${id}' definitionIdEnv`);
+ if (!ENV_NAME_RE.test(definitionIdEnv)) {
+ fail(`lane '${id}' definitionIdEnv '${definitionIdEnv}' must match ${ENV_NAME_RE}`);
+ }
+ lanes.push({
+ id,
+ definitionIdEnv,
+ description: lane.description === undefined ? undefined : asString(lane.description, `lane '${id}' description`),
+ });
+ }
+ if (lanes.length === 0) fail("lanes must declare at least one lane");
+
+ const envSeen = new Map();
+ for (const lane of lanes) {
+ const existing = envSeen.get(lane.definitionIdEnv);
+ if (existing) {
+ fail(`lanes '${existing}' and '${lane.id}' share definitionIdEnv '${lane.definitionIdEnv}'`);
+ }
+ envSeen.set(lane.definitionIdEnv, lane.id);
+ }
+
+ const laneIds = new Set(lanes.map((lane) => lane.id));
+ const cases: SmokeCase[] = [];
+ const seenIds = new Set();
+
+ for (const [i, raw] of asArray(root.cases, "cases").entries()) {
+ const entry = asRecord(raw, `cases[${i}]`);
+ const id = asString(entry.id, `cases[${i}].id`);
+ if (!CASE_ID_RE.test(id)) {
+ fail(`case id '${id}' must match ${CASE_ID_RE} (it becomes a git ref segment)`);
+ }
+ if (seenIds.has(id)) fail(`duplicate case id '${id}'`);
+ seenIds.add(id);
+
+ const lane = asString(entry.lane, `case '${id}' lane`);
+ if (!laneIds.has(lane)) {
+ fail(`case '${id}' references unknown lane '${lane}'`);
+ }
+
+ const kind = asString(entry.kind, `case '${id}' kind`) as CaseKind;
+ if (!CASE_KINDS.includes(kind)) {
+ fail(`case '${id}' kind '${kind}' must be one of ${CASE_KINDS.join(", ")}`);
+ }
+
+ const modes = asStringArray(entry.modes, `case '${id}' modes`) as CompilerSource[];
+ if (modes.length === 0) fail(`case '${id}' modes must not be empty`);
+ for (const mode of modes) {
+ if (!COMPILER_SOURCES.includes(mode)) {
+ fail(`case '${id}' mode '${mode}' must be one of ${COMPILER_SOURCES.join(", ")}`);
+ }
+ }
+ if (new Set(modes).size !== modes.length) {
+ fail(`case '${id}' modes must not contain duplicates`);
+ }
+
+ const source = validateSourcePath(entry.source, id);
+ validateKindMatchesExtension(kind, source, id);
+
+ cases.push({ id, lane, kind, modes, source, assertions: parseAssertions(entry.assertions, id) });
+ }
+
+ if (cases.length === 0) fail("cases must declare at least one case");
+
+ for (const mode of COMPILER_SOURCES) {
+ if (!cases.some((entry) => entry.modes.includes(mode))) {
+ fail(`no case participates in mode '${mode}'`);
+ }
+ }
+
+ return { yamlPath, lanes, cases };
+}
+
+/** Parse a lane definition id from the environment. Strict: positive integers only. */
+function laneDefinitionId(env: NodeJS.ProcessEnv, lane: SmokeLane): number {
+ const raw = env[lane.definitionIdEnv]?.trim();
+ if (!raw || /^\$\([^)]*\)$/.test(raw)) {
+ throw new Error(
+ `lane '${lane.id}' requires env var ${lane.definitionIdEnv} (unset, empty, or an unexpanded ADO macro)`,
+ );
+ }
+ const parsed = Number(raw);
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ throw new Error(`${lane.definitionIdEnv} must be a positive integer (got '${raw}')`);
+ }
+ return parsed;
+}
+
+/**
+ * Load the manifest from the detached candidate worktree and resolve every
+ * case participating in `mode` to its lane's ADO definition id.
+ *
+ * Read from the worktree — an exact checkout of `BUILD_SOURCEVERSION` — rather
+ * than `BUILD_SOURCESDIRECTORY`, which may sit at a different commit. Only the
+ * lanes actually used by `mode` require their env var to be set.
+ */
+export async function loadCases(
+ worktreeDir: string,
+ env: NodeJS.ProcessEnv,
+ mode: CompilerSource,
+): Promise {
+ const text = await readFile(join(worktreeDir, CASES_MANIFEST_PATH), "utf8");
+ const manifest = parseManifest(text);
+
+ const selected = manifest.cases.filter((entry) => entry.modes.includes(mode));
+ if (selected.length === 0) {
+ throw new Error(`${CASES_MANIFEST_PATH}: no case participates in mode '${mode}'`);
+ }
+
+ const lanesById = new Map(manifest.lanes.map((lane) => [lane.id, lane]));
+ const idByLane = new Map();
+ for (const entry of selected) {
+ if (idByLane.has(entry.lane)) continue;
+ idByLane.set(entry.lane, laneDefinitionId(env, lanesById.get(entry.lane)!));
+ }
+
+ const seen = new Map();
+ for (const [lane, id] of idByLane) {
+ const existing = seen.get(id);
+ if (existing) {
+ throw new Error(`lanes '${existing}' and '${lane}' resolve to the same definition id ${id}`);
+ }
+ seen.set(id, lane);
+ }
+
+ return {
+ yamlPath: manifest.yamlPath,
+ mode,
+ cases: selected.map((entry) => ({ ...entry, definitionId: idByLane.get(entry.lane)! })),
+ laneDefinitionIds: [...idByLane.values()],
+ };
+}
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/config.ts b/scripts/ado-script/src/compiler-smoke-e2e/config.ts
index f163c029..cbaacbdd 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/config.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/config.ts
@@ -1,24 +1,25 @@
/**
- * Environment configuration for the deterministic compiler-smoke E2E harness.
+ * Environment configuration for the deterministic smoke E2E harness.
*
- * This harness stages the compiler candidate produced by the current build as
- * a pinned `supply-chain.pipeline-artifact` source across five registered ADO
- * pipeline fixtures, pushes the staged candidate to a per-run branch on a
- * mirror repo, queues all five, and asserts they all go green.
+ * This harness stages one pipeline per smoke *case* onto a per-case branch of
+ * the mirror repo, then queues each case against its credential *lane*
+ * definition and asserts they all go green. Which cases run, and which lane
+ * each belongs to, is declared in `tests/smoke/cases.json` and loaded by
+ * `cases.ts` — this module only handles the environment.
*
* Strict, fail-closed parsing lives here so every other module can trust a
- * fully validated {@link CompilerSmokeConfig} rather than re-checking env
- * vars ad hoc.
+ * fully validated {@link SmokeConfig} rather than re-checking env vars ad hoc.
*
* Test-harness module; not shipped in `ado-script.zip`.
*/
+import { COMPILER_SOURCES, type CompilerSource } from "./cases.js";
/** Per-run candidate branch prefix (never the base ref). */
export const CANDIDATE_BRANCH_PREFIX = "ado-aw-smoke-candidate";
export const DEFAULT_CONCURRENCY = 5;
export const MIN_CONCURRENCY = 1;
-export const MAX_CONCURRENCY = 5;
+export const MAX_CONCURRENCY = 10;
export const DEFAULT_CHILD_TIMEOUT_MS = 7_200_000;
export const DEFAULT_POLL_MS = 10_000;
@@ -26,19 +27,7 @@ export const DEFAULT_POLL_MS = 10_000;
export const DEFAULT_STALE_REF_HOURS = 24;
export const MIN_STALE_REF_HOURS = 6;
-/** Stable declaration order for the five workflows in the live candidate lane. */
-export const CANDIDATE_FIXTURE_NAMES = [
- "canary",
- "azure-cli",
- "noop-target",
- "smoke-failure-reporter",
- "custom-safe-output",
- "multi-repo",
-] as const;
-
-export type FixtureName = (typeof CANDIDATE_FIXTURE_NAMES)[number];
-
-export interface CompilerSmokeConfig {
+export interface SmokeConfig {
/** ADO collection URI, e.g. https://dev.azure.com/org/. */
readonly orgUrl: string;
/** ADO project name (also the pinned pipeline-artifact project). */
@@ -47,25 +36,31 @@ export interface CompilerSmokeConfig {
readonly token: string;
/** Current orchestrator build id (also the pinned pipeline-artifact run-id). */
readonly buildId: number;
- /** Full ref of the checked-out base branch, e.g. refs/heads/main. Never used as the candidate ref. */
+ /** Full ref of the checked-out base branch, e.g. refs/heads/main. Never used as a candidate ref. */
readonly sourceBranch: string;
- /** Commit SHA of the checked-out base branch — the candidate commit's parent context. */
+ /** Commit SHA of the checked-out base branch — every candidate commit's parent. */
readonly sourceVersion: string;
/** Local checkout root (self repo), used as the base for the detached worktree. */
readonly sourcesDirectory: string;
/** This orchestrator pipeline's own definition id (used to age-check stale candidate refs). */
readonly definitionId: number;
- /** Path to the candidate ado-aw binary under test. */
+ /** Path to the `ado-aw` binary under test (candidate build, or downloaded release). */
readonly adoAwBin: string;
- /** Pipeline artifact name pinned into each fixture's supply-chain config. */
+ /**
+ * Which compiler the staged pipelines are built with.
+ *
+ * `candidate` pins every case to this run's own pipeline artifact;
+ * `released` leaves the compiled output pointing at public release assets so
+ * release packaging is exercised too.
+ */
+ readonly compilerSource: CompilerSource;
+ /** Pipeline artifact name pinned into each case (candidate mode only). */
readonly artifactName: string;
- /** ADO Git repo hosting the five registered candidate definitions. */
+ /** ADO Git repo hosting the registered lane definitions. */
readonly mirrorRepo: string;
- /** Registered ADO pipeline definition id, keyed by fixture name. */
- readonly definitionIds: Readonly>;
- /** Bounded fixture polling concurrency (1..5, default 5). */
+ /** Bounded case polling concurrency (1..10, default 5). */
readonly concurrency: number;
- /** Bounded per-fixture build wait, in ms (default 2h). */
+ /** Bounded per-case build wait, in ms (default 2h). */
readonly childTimeoutMs: number;
/** Build poll interval, in ms (default 10s). */
readonly pollMs: number;
@@ -80,20 +75,11 @@ const REQUIRED_STRING_VARS = [
"BUILD_SOURCEBRANCH",
"BUILD_SOURCEVERSION",
"BUILD_SOURCESDIRECTORY",
- "COMPILER_SMOKE_ADO_AW_BIN",
- "COMPILER_SMOKE_ARTIFACT_NAME",
- "COMPILER_SMOKE_MIRROR_REPO",
+ "SMOKE_ADO_AW_BIN",
+ "SMOKE_ARTIFACT_NAME",
+ "SMOKE_MIRROR_REPO",
] as const;
-const DEFINITION_ID_ENV_BY_FIXTURE: Readonly> = {
- canary: "COMPILER_SMOKE_CANARY_DEFINITION_ID",
- "azure-cli": "COMPILER_SMOKE_AZURE_CLI_DEFINITION_ID",
- "noop-target": "COMPILER_SMOKE_NOOP_TARGET_DEFINITION_ID",
- "smoke-failure-reporter": "COMPILER_SMOKE_REPORTER_DEFINITION_ID",
- "custom-safe-output": "COMPILER_SMOKE_CUSTOM_SAFE_OUTPUT_DEFINITION_ID",
- "multi-repo": "COMPILER_SMOKE_MULTI_REPO_DEFINITION_ID",
-};
-
/** ADO macros that failed to expand look like `$(NAME)`; treat them as unset. */
const UNEXPANDED_MACRO_RE = /^\$\([^)]*\)$/;
@@ -143,87 +129,69 @@ function optionalBoundedInt(
return parsed;
}
+/**
+ * Parse the compiler-source mode.
+ *
+ * Deliberately required rather than defaulted: the two modes assert opposite
+ * things about release URLs, so silently guessing would turn a misconfigured
+ * pipeline into a green run that checked nothing.
+ */
+function requireCompilerSource(env: NodeJS.ProcessEnv): CompilerSource {
+ const raw = requireString(env, "SMOKE_COMPILER_SOURCE");
+ if (!COMPILER_SOURCES.includes(raw as CompilerSource)) {
+ throw new Error(
+ `SMOKE_COMPILER_SOURCE must be one of ${COMPILER_SOURCES.join(", ")} (got '${raw}')`,
+ );
+ }
+ return raw as CompilerSource;
+}
+
/** Load and strictly validate the harness configuration. Throws on any invalid input. */
-export function loadConfig(env: NodeJS.ProcessEnv = process.env): CompilerSmokeConfig {
+export function loadConfig(env: NodeJS.ProcessEnv = process.env): SmokeConfig {
for (const name of REQUIRED_STRING_VARS) {
requireString(env, name);
}
- const orgUrl = requireString(env, "SYSTEM_COLLECTIONURI");
- const project = requireString(env, "SYSTEM_TEAMPROJECT");
- const token = requireString(env, "SYSTEM_ACCESSTOKEN");
- const sourceBranch = requireString(env, "BUILD_SOURCEBRANCH");
- const sourceVersion = requireString(env, "BUILD_SOURCEVERSION");
- const sourcesDirectory = requireString(env, "BUILD_SOURCESDIRECTORY");
- const adoAwBin = requireString(env, "COMPILER_SMOKE_ADO_AW_BIN");
- const artifactName = requireString(env, "COMPILER_SMOKE_ARTIFACT_NAME");
- const mirrorRepo = requireString(env, "COMPILER_SMOKE_MIRROR_REPO");
-
- const buildId = requirePositiveInt(env, "BUILD_BUILDID");
- const definitionId = requirePositiveInt(env, "SYSTEM_DEFINITIONID");
-
- const definitionIds = {} as Record;
- for (const fixture of CANDIDATE_FIXTURE_NAMES) {
- definitionIds[fixture] = requirePositiveInt(env, DEFINITION_ID_ENV_BY_FIXTURE[fixture]);
- }
-
- const seen = new Map();
- for (const fixture of CANDIDATE_FIXTURE_NAMES) {
- const id = definitionIds[fixture];
- const existing = seen.get(id);
- if (existing) {
- existing.push(fixture);
- } else {
- seen.set(id, [fixture]);
- }
- }
- const duplicates = [...seen.entries()].filter(([, fixtures]) => fixtures.length > 1);
- if (duplicates.length > 0) {
- const detail = duplicates
- .map(([id, fixtures]) => `${id} used by [${fixtures.join(", ")}]`)
- .join("; ");
- throw new Error(`fixture definition ids must be distinct; duplicates found: ${detail}`);
- }
-
- const concurrency = optionalBoundedInt(env, "COMPILER_SMOKE_CONCURRENCY", {
- default: DEFAULT_CONCURRENCY,
- min: MIN_CONCURRENCY,
- max: MAX_CONCURRENCY,
- });
- const childTimeoutMs = optionalBoundedInt(env, "COMPILER_SMOKE_CHILD_TIMEOUT_MS", {
- default: DEFAULT_CHILD_TIMEOUT_MS,
- min: 1,
- });
- const pollMs = optionalBoundedInt(env, "COMPILER_SMOKE_POLL_MS", {
- default: DEFAULT_POLL_MS,
- min: 1,
- });
- const staleRefHours = optionalBoundedInt(env, "COMPILER_SMOKE_STALE_REF_HOURS", {
- default: DEFAULT_STALE_REF_HOURS,
- min: MIN_STALE_REF_HOURS,
- });
-
return {
- orgUrl,
- project,
- token,
- buildId,
- sourceBranch,
- sourceVersion,
- sourcesDirectory,
- definitionId,
- adoAwBin,
- artifactName,
- mirrorRepo,
- definitionIds,
- concurrency,
- childTimeoutMs,
- pollMs,
- staleRefHours,
+ orgUrl: requireString(env, "SYSTEM_COLLECTIONURI"),
+ project: requireString(env, "SYSTEM_TEAMPROJECT"),
+ token: requireString(env, "SYSTEM_ACCESSTOKEN"),
+ sourceBranch: requireString(env, "BUILD_SOURCEBRANCH"),
+ sourceVersion: requireString(env, "BUILD_SOURCEVERSION"),
+ sourcesDirectory: requireString(env, "BUILD_SOURCESDIRECTORY"),
+ adoAwBin: requireString(env, "SMOKE_ADO_AW_BIN"),
+ artifactName: requireString(env, "SMOKE_ARTIFACT_NAME"),
+ mirrorRepo: requireString(env, "SMOKE_MIRROR_REPO"),
+ compilerSource: requireCompilerSource(env),
+ buildId: requirePositiveInt(env, "BUILD_BUILDID"),
+ definitionId: requirePositiveInt(env, "SYSTEM_DEFINITIONID"),
+ concurrency: optionalBoundedInt(env, "SMOKE_CONCURRENCY", {
+ default: DEFAULT_CONCURRENCY,
+ min: MIN_CONCURRENCY,
+ max: MAX_CONCURRENCY,
+ }),
+ childTimeoutMs: optionalBoundedInt(env, "SMOKE_CHILD_TIMEOUT_MS", {
+ default: DEFAULT_CHILD_TIMEOUT_MS,
+ min: 1,
+ }),
+ pollMs: optionalBoundedInt(env, "SMOKE_POLL_MS", {
+ default: DEFAULT_POLL_MS,
+ min: 1,
+ }),
+ staleRefHours: optionalBoundedInt(env, "SMOKE_STALE_REF_HOURS", {
+ default: DEFAULT_STALE_REF_HOURS,
+ min: MIN_STALE_REF_HOURS,
+ }),
};
}
-/** Deterministic per-run candidate ref, e.g. refs/heads/ado-aw-smoke-candidate/12345. Never the base ref. */
-export function candidateRef(buildId: number): string {
- return `refs/heads/${CANDIDATE_BRANCH_PREFIX}/${buildId}`;
+/**
+ * Deterministic per-run, per-case candidate ref, e.g.
+ * `refs/heads/ado-aw-smoke-candidate/12345/canary`. Never the base ref.
+ *
+ * The case id is validated by the manifest loader before reaching here, so it
+ * is safe to interpolate into a ref name.
+ */
+export function candidateRef(buildId: number, caseId: string): string {
+ return `refs/heads/${CANDIDATE_BRANCH_PREFIX}/${buildId}/${caseId}`;
}
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/fixtures.ts b/scripts/ado-script/src/compiler-smoke-e2e/fixtures.ts
deleted file mode 100644
index 0a844805..00000000
--- a/scripts/ado-script/src/compiler-smoke-e2e/fixtures.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * Manifest of the fixed compiler-smoke fixtures.
- *
- * Four reuse release-backed sources under `tests/safe-outputs/`; the rest are
- * candidate-only and live beside this harness. The weekly janitor is
- * deliberately excluded from candidate checks. The harness reads every source
- * from the detached candidate worktree (an exact checkout of
- * `BUILD_SOURCEVERSION`, never `BUILD_SOURCESDIRECTORY`, which may sit at a
- * different commit), stages a pinned `supply-chain.pipeline-artifact`
- * transform, recompiles, and queues the fixed candidate-lane definitions
- * tracked in `tests/compiler-smoke-e2e/REGISTERED.md`.
- *
- * Test-harness module; not shipped in `ado-script.zip`.
- */
-import type { FixtureName } from "./config.js";
-import { CANDIDATE_FIXTURE_NAMES } from "./config.js";
-
-/** Repo-relative directory containing release-backed fixture sources. */
-export const RELEASE_FIXTURE_DIR = "tests/safe-outputs";
-/** Repo-relative directory containing the candidate-only custom fixture. */
-export const CANDIDATE_FIXTURE_DIR = "tests/compiler-smoke-e2e";
-
-export interface FixturePaths {
- readonly name: FixtureName;
- /** Repo-relative path to the fixture markdown source, e.g. tests/safe-outputs/canary.md. */
- readonly relMd: string;
- /** Repo-relative path to the compiled lock file, e.g. tests/safe-outputs/canary.lock.yml. */
- readonly relLock: string;
- /** Observable ADO build tags that must exist after this child succeeds. */
- readonly requiredBuildTags?: (buildId: number) => readonly string[];
-}
-
-/** Repo-relative paths and signal contract for one fixture. */
-export function fixturePaths(name: FixtureName): FixturePaths {
- const candidateOnly = name === "custom-safe-output" || name === "multi-repo";
- const directory = candidateOnly ? CANDIDATE_FIXTURE_DIR : RELEASE_FIXTURE_DIR;
- const requiredBuildTags =
- name === "custom-safe-output"
- ? (buildId: number): readonly string[] => [`ado-aw-custom-job-${buildId}`]
- : undefined;
- return {
- name,
- relMd: `${directory}/${name}.md`,
- relLock: `${directory}/${name}.lock.yml`,
- requiredBuildTags,
- };
-}
-
-/** Every candidate fixture in the stable order used throughout the harness. */
-export const ALL_FIXTURES: readonly FixturePaths[] =
- CANDIDATE_FIXTURE_NAMES.map(fixturePaths);
-
-export function fixtureByName(name: FixtureName): FixturePaths {
- const fixture = ALL_FIXTURES.find((candidate) => candidate.name === name);
- if (!fixture) {
- throw new Error(`unknown compiler-smoke fixture '${name}'`);
- }
- return fixture;
-}
-
-/**
- * The exact set of repo-relative paths the candidate-staging commit may touch:
- * every markdown source, its compiled lock, and the compiler-managed root
- * `.gitattributes` block. Any other changed path fails before push.
- */
-export function allowedChangedPaths(): Set {
- const paths = new Set([".gitattributes"]);
- for (const fixture of ALL_FIXTURES) {
- paths.add(fixture.relMd);
- paths.add(fixture.relLock);
- }
- return paths;
-}
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/git.ts b/scripts/ado-script/src/compiler-smoke-e2e/git.ts
index 300f50d7..e9ee04aa 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/git.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/git.ts
@@ -1,8 +1,8 @@
/**
- * Git operations for staging a compiler candidate onto the mirror repo:
- * fetch the base ref into the self checkout's object store, spin up a
- * detached temp worktree, commit the transformed fixtures, push to a
- * per-run candidate ref, verify, and clean up (remote ref + worktree).
+ * Git operations for staging smoke cases onto the mirror repo: spin up a
+ * detached temp worktree, and for each case commit the staged pipeline, push
+ * it to a per-case candidate ref, verify, then reset ready for the next case.
+ * Cleans up the remote refs and the worktree at the end.
*
* Also implements the startup stale-ref scanner (see {@link scanStaleRefs}
* usage in `index.ts`).
@@ -46,9 +46,9 @@ export const COMMIT_IDENTITY = {
email: "ado-aw-smoke-e2e@users.noreply.github.com",
} as const;
-/** Deterministic commit message: `test(smoke): stage compiler candidate `. */
-export function commitMessage(buildId: number): string {
- return `test(smoke): stage compiler candidate ${buildId}`;
+/** Deterministic commit message: `test(smoke): stage for candidate `. */
+export function commitMessage(buildId: number, caseId: string): string {
+ return `test(smoke): stage ${caseId} for candidate ${buildId}`;
}
/** Build the ADO Git remote URL for `//_git/`. */
@@ -179,7 +179,7 @@ export function disallowedChanges(changed: readonly string[], allowed: ReadonlyS
/** Stage all changes and commit with the deterministic identity/message. Returns the new commit SHA. */
export async function commitAll(
- opts: { worktreeDir: string; buildId: number; timeoutMs: number },
+ opts: { worktreeDir: string; buildId: number; caseId: string; timeoutMs: number },
runner: GitRunner = defaultGitRunner,
): Promise {
await run(["add", "-A"], { cwd: opts.worktreeDir, timeoutMs: opts.timeoutMs }, runner);
@@ -191,7 +191,7 @@ export async function commitAll(
`user.email=${COMMIT_IDENTITY.email}`,
"commit",
"-m",
- commitMessage(opts.buildId),
+ commitMessage(opts.buildId, opts.caseId),
],
{ cwd: opts.worktreeDir, timeoutMs: opts.timeoutMs },
runner,
@@ -199,6 +199,29 @@ export async function commitAll(
return run(["rev-parse", "HEAD"], { cwd: opts.worktreeDir, timeoutMs: opts.timeoutMs }, runner);
}
+/**
+ * Hard-reset the worktree back to `commitish` and remove untracked files.
+ *
+ * Called between cases so every candidate commit is a *sibling* parented
+ * directly on `BUILD_SOURCEVERSION` rather than a chain — each per-case ref
+ * then contains exactly its own case's staged pipeline, and all refs share
+ * the bulk of their objects so the pushes stay cheap.
+ *
+ * `clean -fdx` is deliberate: the compiler writes generated artefacts (lock
+ * files, `.ado-aw/imports/`) that must not leak from one case into the next.
+ */
+export async function resetWorktree(
+ opts: { worktreeDir: string; commitish: string; timeoutMs: number },
+ runner: GitRunner = defaultGitRunner,
+): Promise {
+ await run(
+ ["reset", "--hard", opts.commitish],
+ { cwd: opts.worktreeDir, timeoutMs: opts.timeoutMs },
+ runner,
+ );
+ await run(["clean", "-fdx"], { cwd: opts.worktreeDir, timeoutMs: opts.timeoutMs }, runner);
+}
+
/** Push the worktree's HEAD to `ref` on the mirror repo (never force). */
export async function pushCandidate(
opts: { worktreeDir: string; mirrorUrl: string; ref: string; token: string; timeoutMs: number },
@@ -238,13 +261,54 @@ export async function deleteRemoteRef(
opts: { cwd: string; mirrorUrl: string; ref: string; token: string; timeoutMs: number },
runner: GitRunner = defaultGitRunner,
): Promise {
+ await deleteRemoteRefs({ ...opts, refs: [opts.ref] }, runner);
+}
+
+/**
+ * Delete one or more candidate refs on the mirror repo in a single push.
+ *
+ * Batched because the lane model creates one ref per case per run, so a
+ * five-case run would otherwise pay five round trips. Falls back to
+ * individual deletes if the batch fails, so one bad ref cannot strand the
+ * rest.
+ */
+export async function deleteRemoteRefs(
+ opts: { cwd: string; mirrorUrl: string; refs: readonly string[]; token: string; timeoutMs: number },
+ runner: GitRunner = defaultGitRunner,
+): Promise {
+ if (opts.refs.length === 0) return;
const env = bearerEnv(opts.token);
- await run(
- ["push", "--porcelain", opts.mirrorUrl, "--delete", opts.ref],
- { cwd: opts.cwd, env, timeoutMs: opts.timeoutMs },
- runner,
- [opts.token],
- );
+ const run1 = (refs: readonly string[]): Promise =>
+ run(
+ ["push", "--porcelain", opts.mirrorUrl, "--delete", ...refs],
+ { cwd: opts.cwd, env, timeoutMs: opts.timeoutMs },
+ runner,
+ [opts.token],
+ );
+
+ if (opts.refs.length === 1) {
+ await run1(opts.refs);
+ return;
+ }
+
+ try {
+ await run1(opts.refs);
+ } catch (batchErr) {
+ const failures: string[] = [];
+ for (const ref of opts.refs) {
+ try {
+ await run1([ref]);
+ } catch (err) {
+ failures.push(`${ref}: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+ if (failures.length > 0) {
+ throw new Error(
+ `batched ref delete failed (${batchErr instanceof Error ? batchErr.message : String(batchErr)}); ` +
+ `per-ref fallback also failed for: ${failures.join("; ")}`,
+ );
+ }
+ }
}
export interface RemoteRef {
@@ -259,7 +323,11 @@ export async function listCandidateRefs(
): Promise {
const env = bearerEnv(opts.token);
const stdout = await run(
- ["ls-remote", "--heads", opts.mirrorUrl, `refs/heads/${CANDIDATE_BRANCH_PREFIX}/*`],
+ // `**` rather than `*`: candidate refs now carry a per-case segment
+ // (`/`), and some git/server implementations do not match
+ // `/` with a single `*`. The exact-prefix guard below remains the real
+ // filter either way.
+ ["ls-remote", "--heads", opts.mirrorUrl, `refs/heads/${CANDIDATE_BRANCH_PREFIX}/**`],
{ cwd: opts.cwd, env, timeoutMs: opts.timeoutMs },
runner,
[opts.token],
@@ -280,12 +348,27 @@ export async function listCandidateRefs(
return refs;
}
-/** Parse the numeric build id embedded in a candidate ref name, or `undefined` if malformed. */
-export function parseCandidateBuildId(ref: string): number | undefined {
+/** A parsed candidate ref: the orchestrator build that created it, and the case it stages. */
+export interface ParsedCandidateRef {
+ readonly buildId: number;
+ readonly caseId: string;
+}
+
+/**
+ * Parse the build id and case id out of a candidate ref, or `undefined` if the
+ * ref does not match `refs/heads///` exactly.
+ *
+ * The `caseId` pattern mirrors the manifest's `CASE_ID_RE`. Anything that
+ * fails to parse is reported as ambiguous by the stale-ref scanner and never
+ * deleted — a fail-closed posture is preferred over guessing at another run's
+ * identity.
+ */
+export function parseCandidateRef(ref: string): ParsedCandidateRef | undefined {
const prefix = `refs/heads/${CANDIDATE_BRANCH_PREFIX}/`;
if (!ref.startsWith(prefix)) return undefined;
- const suffix = ref.slice(prefix.length);
- if (!/^[0-9]+$/.test(suffix)) return undefined;
- const id = Number(suffix);
- return Number.isSafeInteger(id) && id > 0 ? id : undefined;
+ const match = /^([0-9]+)\/([a-z0-9][a-z0-9-]{0,48})$/.exec(ref.slice(prefix.length));
+ if (!match) return undefined;
+ const buildId = Number(match[1]);
+ if (!Number.isSafeInteger(buildId) || buildId <= 0) return undefined;
+ return { buildId, caseId: match[2]! };
}
diff --git a/scripts/ado-script/src/compiler-smoke-e2e/index.ts b/scripts/ado-script/src/compiler-smoke-e2e/index.ts
index 219e75a8..bc854c55 100644
--- a/scripts/ado-script/src/compiler-smoke-e2e/index.ts
+++ b/scripts/ado-script/src/compiler-smoke-e2e/index.ts
@@ -1,12 +1,24 @@
/**
- * Entry point for the deterministic compiler-smoke E2E orchestrator.
+ * Entry point for the deterministic smoke E2E orchestrator.
*
- * Stages the compiler candidate produced by the current build (PR or
- * nightly `main`) as a pinned `supply-chain.pipeline-artifact` source across
- * five fixed fixtures, pushes the staged candidate to a short-lived branch on
- * the mirror repo, queues the FIXED "candidate lane" pipeline definitions
- * (tracked in
- * `tests/compiler-smoke-e2e/REGISTERED.md`), and asserts they all go green.
+ * Stages every smoke case declared in `tests/smoke/cases.json` for the current
+ * compiler-source mode, pushes each one to its own short-lived branch on the
+ * mirror repo, queues each against its credential *lane* definition, and
+ * asserts they all go green.
+ *
+ * The lane model inverts the old mapping: a definition is a credential
+ * boundary, not a test case. Cases are told apart by their per-case ref
+ * (`refs/heads/ado-aw-smoke-candidate//`), all staged to the
+ * same fixed `.smoke/pipeline.yml` path, so adding a smoke costs a markdown
+ * file and a manifest entry — no ADO definition registration.
+ *
+ * Two modes (`SMOKE_COMPILER_SOURCE`):
+ * - `candidate` — compiles with the binary built from this run's commit and
+ * pins every case to this run's own pipeline artifact.
+ * - `released` — compiles with the latest released binary and leaves the
+ * output pointing at public release assets, so release packaging and
+ * asset availability are exercised. This replaces the retired committed
+ * `*.lock.yml` files.
*
* See `config.ts` for the full required/optional env var contract.
*
@@ -14,40 +26,39 @@
*/
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { dirname, join } from "node:path";
+import { mkdir } from "node:fs/promises";
import { AdoRest } from "./ado-rest.js";
import {
assertAgentCommandPolicy,
assertAdoTokenIsolation,
assertNoForbiddenReleaseUrls,
+ assertNoTriggers,
assertPipelineArtifactValues,
+ assertReleaseUrlsPresent,
} from "./assertions.js";
-import {
- candidateRef,
- CANDIDATE_FIXTURE_NAMES,
- loadConfig,
- type CompilerSmokeConfig,
-} from "./config.js";
+import { loadCases, type ResolvedCase, type ResolvedCases } from "./cases.js";
+import { candidateRef, loadConfig, type SmokeConfig } from "./config.js";
import { compileAndCheck } from "./compile-cli.js";
-import { ALL_FIXTURES, allowedChangedPaths } from "./fixtures.js";
import {
commitAll,
createDetachedWorktree,
- deleteRemoteRef,
+ deleteRemoteRefs,
disallowedChanges,
listCandidateRefs,
mirrorRepoUrl,
pushCandidate,
removeWorktree,
+ resetWorktree,
verifyLocalCommit,
verifyRemoteRef,
worktreeChangedFiles,
} from "./git.js";
-import { injectPipelineArtifact } from "./source.js";
+import { prepareCaseSource } from "./source.js";
import { renderResultsTable } from "./report.js";
import { runFixtures, type FixtureBuildRequest, type FixtureBuildResult } from "./runner.js";
-import { verifyFixtureSignals } from "./signals.js";
+import { verifyCaseSignals } from "./signals.js";
import { scanStaleRefs } from "./stale.js";
function log(msg: string): void {
@@ -59,62 +70,180 @@ function errMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
-/** Read each fixture's markdown directly from the detached candidate worktree (an exact checkout of BUILD_SOURCEVERSION — never the possibly-divergent BUILD_SOURCESDIRECTORY), then apply the pipeline-artifact transform in place. */
-async function stageFixtures(config: CompilerSmokeConfig, worktreeDir: string): Promise {
- for (const fixture of ALL_FIXTURES) {
- const selfContent = await readFile(join(worktreeDir, fixture.relMd), "utf8");
- const transformed = injectPipelineArtifact(selfContent, {
+/** Repo-relative lock path the compiler writes for a given markdown source. */
+function lockPathFor(relMd: string): string {
+ return `${relMd.slice(0, -".md".length)}.lock.yml`;
+}
+
+/**
+ * The exact set of repo-relative paths ONE case's staging commit may touch.
+ *
+ * Deliberately exact-match and per-case (rather than a union across every
+ * case): the worktree is reset between cases, so a change belonging to a
+ * different case indicates the reset failed and must abort before push.
+ */
+function allowedChangedPaths(entry: ResolvedCase, yamlPath: string): Set {
+ const paths = new Set([".gitattributes", yamlPath]);
+ if (entry.kind === "compiled") {
+ paths.add(entry.source);
+ paths.add(lockPathFor(entry.source));
+ }
+ return paths;
+}
+
+/**
+ * Produce one case's `.smoke/pipeline.yml` inside the worktree.
+ *
+ * `compiled` cases are transformed, compiled by the binary under test, and
+ * asserted; `raw` cases are copied verbatim. Both end with the same fixed
+ * path staged and `assertNoTriggers` enforced.
+ */
+async function stageCase(
+ config: SmokeConfig,
+ resolved: ResolvedCases,
+ entry: ResolvedCase,
+ worktreeDir: string,
+ mirrorUrl: string,
+): Promise {
+ const target = join(worktreeDir, resolved.yamlPath);
+ await mkdir(dirname(target), { recursive: true });
+
+ if (entry.kind === "raw") {
+ // No compiler runs here, so a raw source must declare `trigger: none` /
+ // `pr: none` itself; `assertNoTriggers` is what enforces that.
+ const raw = await readFile(join(worktreeDir, entry.source), "utf8");
+ await writeFile(target, raw, "utf8");
+ assertNoTriggers(raw, entry.id);
+ return;
+ }
+
+ const relMd = entry.source;
+ const relLock = lockPathFor(relMd);
+
+ const original = await readFile(join(worktreeDir, relMd), "utf8");
+ // Candidate mode pins every binary to this run's own artifact; released mode
+ // deliberately leaves the release URLs in place so asset download is tested.
+ const artifact =
+ resolved.mode === "candidate"
+ ? {
+ project: config.project,
+ definitionId: config.definitionId,
+ runId: config.buildId,
+ artifact: config.artifactName,
+ }
+ : undefined;
+ await writeFile(join(worktreeDir, relMd), prepareCaseSource(original, artifact), "utf8");
+
+ const result = await compileAndCheck({
+ adoAwBin: config.adoAwBin,
+ worktreeDir,
+ metadataRemoteUrl: mirrorUrl,
+ relMd,
+ relLock,
+ timeoutMs: config.childTimeoutMs,
+ secrets: [config.token],
+ });
+ if (!result.ok) {
+ throw new Error(
+ `case '${entry.id}' ${result.phase} failed: ${result.message}\n--- stdout ---\n${result.stdout}\n--- stderr ---\n${result.stderr}`,
+ );
+ }
+
+ const yamlText = await readFile(join(worktreeDir, relLock), "utf8");
+ assertAdoTokenIsolation(yamlText, entry.id);
+
+ if (resolved.mode === "candidate") {
+ assertNoForbiddenReleaseUrls(yamlText, entry.id);
+ assertPipelineArtifactValues(yamlText, entry.id, {
project: config.project,
- definitionId: config.definitionId,
- runId: config.buildId,
+ pipeline: String(config.definitionId),
+ runId: String(config.buildId),
artifact: config.artifactName,
});
- await writeFile(join(worktreeDir, fixture.relMd), transformed, "utf8");
+ } else {
+ assertReleaseUrlsPresent(yamlText, entry.id);
+ }
+
+ const agentCommand = entry.assertions?.agentCommand;
+ if (agentCommand) {
+ assertAgentCommandPolicy(yamlText, entry.id, agentCommand.required, agentCommand.forbidden);
}
+
+ // The staged copy is byte-identical to the compiled lock, so the pipeline's
+ // own runtime `ado-aw check ` integrity step still passes. Stripping
+ // `on:` is what makes the compiler emit `trigger: none` / `pr: none`;
+ // assert it on the staged bytes rather than trusting it.
+ await writeFile(target, yamlText, "utf8");
+ assertNoTriggers(yamlText, entry.id);
}
-async function compileFixtures(
- config: CompilerSmokeConfig,
+/** Stage, commit and push every case, returning the per-case ref and commit SHA. */
+async function stageAllCases(
+ config: SmokeConfig,
+ resolved: ResolvedCases,
worktreeDir: string,
mirrorUrl: string,
-): Promise {
- for (const fixture of ALL_FIXTURES) {
- const result = await compileAndCheck({
- adoAwBin: config.adoAwBin,
- worktreeDir,
- metadataRemoteUrl: mirrorUrl,
- relMd: fixture.relMd,
- relLock: fixture.relLock,
- timeoutMs: config.childTimeoutMs,
- secrets: [config.token],
- });
- if (!result.ok) {
+ onPushed: (caseId: string, ref: string) => void,
+): Promise