diff --git a/.github/workflows/smoke-service-ports.lock.yml b/.github/workflows/smoke-service-ports.lock.yml
new file mode 100644
index 00000000000..d037c0bd8a7
--- /dev/null
+++ b/.github/workflows/smoke-service-ports.lock.yml
@@ -0,0 +1,1163 @@
+# ___ _ _
+# / _ \ | | (_)
+# | |_| | __ _ ___ _ __ | |_ _ ___
+# | _ |/ _` |/ _ \ '_ \| __| |/ __|
+# | | | | (_| | __/ | | | |_| | (__
+# \_| |_/\__, |\___|_| |_|\__|_|\___|
+# __/ |
+# _ _ |___/
+# | | | | / _| |
+# | | | | ___ _ __ _ __| |_| | _____ ____
+# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
+# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
+# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
+#
+# This file was automatically generated by gh-aw. DO NOT EDIT.
+#
+# 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/
+#
+# Smoke test to validate --allow-host-service-ports with Redis service container
+#
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"f87ff09b47856fe5762f2eb7eddf0eee24e2dd583dd1b53a14f1ed9fef367457","strict":true,"agent_id":"copilot"}
+
+name: "Smoke Service Ports"
+"on":
+ schedule:
+ - cron: "28 21 * * *"
+ workflow_dispatch:
+ inputs:
+ aw_context:
+ default: ""
+ description: Agent caller context (used internally by Agentic Workflows).
+ required: false
+ type: string
+
+permissions: {}
+
+concurrency:
+ group: "gh-aw-${{ github.workflow }}"
+
+run-name: "Smoke Service Ports"
+
+jobs:
+ activation:
+ needs: pre_activation
+ if: needs.pre_activation.outputs.activated == 'true'
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ outputs:
+ comment_id: ${{ steps.add-comment.outputs.comment-id }}
+ comment_repo: ${{ steps.add-comment.outputs.comment-repo }}
+ comment_url: ${{ steps.add-comment.outputs.comment-url }}
+ lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
+ model: ${{ steps.generate_aw_info.outputs.model }}
+ secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - 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 || 'auto' }}
+ GH_AW_INFO_VERSION: "latest"
+ GH_AW_INFO_AGENT_VERSION: "latest"
+ GH_AW_INFO_WORKFLOW_NAME: "Smoke Service Ports"
+ GH_AW_INFO_EXPERIMENTAL: "false"
+ GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
+ GH_AW_INFO_STAGED: "false"
+ GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]'
+ GH_AW_INFO_FIREWALL_ENABLED: "true"
+ GH_AW_INFO_AWF_VERSION: "v0.25.1"
+ GH_AW_INFO_AWMG_VERSION: ""
+ GH_AW_INFO_FIREWALL_TYPE: "squid"
+ GH_AW_COMPILED_STRICT: "true"
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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/generate_aw_info.cjs');
+ await main(core, context);
+ - name: Validate COPILOT_GITHUB_TOKEN secret
+ id: validate-secret
+ run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
+ env:
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ - name: Checkout .github and .agents folders
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ sparse-checkout: |
+ .github
+ .agents
+ actions/setup
+ sparse-checkout-cone-mode: true
+ fetch-depth: 1
+ - name: Check workflow file timestamps
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_WORKFLOW_FILE: "smoke-service-ports.lock.yml"
+ 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/check_workflow_timestamp_api.cjs');
+ await main();
+ - name: Add comment with workflow run link
+ id: add-comment
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_WORKFLOW_NAME: "Smoke Service Ports"
+ GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity...\",\"runSuccess\":\"✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis.\",\"runFailure\":\"❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}\"}"
+ 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/add_workflow_run_comment.cjs');
+ await main();
+ - 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_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ 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_adf052b962132837_EOF'
+
+ GH_AW_PROMPT_adf052b962132837_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_adf052b962132837_EOF'
+
+ Tools: add_comment(max:2), missing_tool, missing_data, noop
+
+
+ The following GitHub context information is available for this workflow:
+ {{#if __GH_AW_GITHUB_ACTOR__ }}
+ - **actor**: __GH_AW_GITHUB_ACTOR__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_REPOSITORY__ }}
+ - **repository**: __GH_AW_GITHUB_REPOSITORY__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_WORKSPACE__ }}
+ - **workspace**: __GH_AW_GITHUB_WORKSPACE__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }}
+ - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }}
+ - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }}
+ - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }}
+ - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_RUN_ID__ }}
+ - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
+ {{/if}}
+
+
+ GH_AW_PROMPT_adf052b962132837_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
+ cat << 'GH_AW_PROMPT_adf052b962132837_EOF'
+
+ GH_AW_PROMPT_adf052b962132837_EOF
+ cat << 'GH_AW_PROMPT_adf052b962132837_EOF'
+ {{#runtime-import .github/workflows/smoke-service-ports.md}}
+ GH_AW_PROMPT_adf052b962132837_EOF
+ } > "$GH_AW_PROMPT"
+ - name: Interpolate variables and render templates
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ 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/interpolate_prompt.cjs');
+ await main();
+ - name: Substitute placeholders
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ 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);
+
+ 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_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
+ GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID,
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER,
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER,
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
+ 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_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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: activation
+ path: |
+ /tmp/gh-aw/aw_info.json
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ retention-days: 1
+
+ agent:
+ needs: activation
+ runs-on: ubuntu-latest
+ services:
+ redis:
+ image: redis:7
+ options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
+ ports:
+ - 6379:6379
+ permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+ concurrency:
+ group: "gh-aw-copilot-${{ github.workflow }}"
+ 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_WORKFLOW_ID_SANITIZED: smokeserviceports
+ outputs:
+ checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
+ has_patch: ${{ steps.collect_output.outputs.has_patch }}
+ inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }}
+ model: ${{ needs.activation.outputs.model }}
+ output: ${{ steps.collect_output.outputs.output }}
+ output_types: ${{ steps.collect_output.outputs.output_types }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Set runtime paths
+ id: set-runtime-paths
+ run: |
+ echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT"
+ echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT"
+ echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT"
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ 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: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git config --global am.keepcr true
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Checkout PR branch
+ id: checkout-pr
+ if: |
+ github.event.pull_request || github.event.issue.pull_request
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
+ await main();
+ - name: Install GitHub Copilot CLI
+ run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest
+ env:
+ GH_HOST: github.com
+ - name: Install AWF binary
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.1
+ - name: Determine automatic lockdown mode for GitHub MCP Server
+ id: determine-automatic-lockdown
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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: Download container images
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.1 ghcr.io/github/gh-aw-firewall/squid:0.25.1 ghcr.io/github/gh-aw-mcpg:v0.2.6 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine
+ - name: Write 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_540b8636a24c7a08_EOF'
+ {"add_comment":{"hide_older_comments":true,"max":2},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_540b8636a24c7a08_EOF
+ - name: Write Safe Outputs Tools
+ run: |
+ cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_66bf068d6618c38e_EOF'
+ {
+ "description_suffixes": {
+ "add_comment": " CONSTRAINTS: Maximum 2 comment(s) can be added."
+ },
+ "repo_params": {},
+ "dynamic_tools": []
+ }
+ GH_AW_SAFE_OUTPUTS_TOOLS_META_66bf068d6618c38e_EOF
+ cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_736754397af27d75_EOF'
+ {
+ "add_comment": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "item_number": {
+ "issueOrPRNumber": true
+ },
+ "repo": {
+ "type": "string",
+ "maxLength": 256
+ }
+ }
+ },
+ "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
+ }
+ }
+ }
+ }
+ GH_AW_SAFE_OUTPUTS_VALIDATION_736754397af27d75_EOF
+ node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs
+ - name: Generate Safe Outputs MCP Server Config
+ id: safe-outputs-config
+ run: |
+ # Generate a secure random API key (360 bits of entropy, 40+ chars)
+ # Mask immediately to prevent timing vulnerabilities
+ API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${API_KEY}"
+
+ PORT=3001
+
+ # Set outputs for next steps
+ {
+ echo "safe_outputs_api_key=${API_KEY}"
+ echo "safe_outputs_port=${PORT}"
+ } >> "$GITHUB_OUTPUT"
+
+ echo "Safe Outputs MCP server will run on port ${PORT}"
+
+ - name: Start Safe Outputs MCP HTTP Server
+ id: safe-outputs-start
+ env:
+ DEBUG: '*'
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }}
+ GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ run: |
+ # Environment variables are set above to prevent template injection
+ export DEBUG
+ export GH_AW_SAFE_OUTPUTS_PORT
+ export GH_AW_SAFE_OUTPUTS_API_KEY
+ export GH_AW_SAFE_OUTPUTS_TOOLS_PATH
+ export GH_AW_SAFE_OUTPUTS_CONFIG_PATH
+ export GH_AW_MCP_LOG_DIR
+
+ bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh
+
+ - name: Start MCP Gateway
+ id: start-mcp-gateway
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
+ 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 }}
+ run: |
+ set -eo pipefail
+ mkdir -p /tmp/gh-aw/mcp-config
+
+ # Export gateway environment variables for MCP config and gateway script
+ export MCP_GATEWAY_PORT="80"
+ export MCP_GATEWAY_DOMAIN="host.docker.internal"
+ 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"
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/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 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_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 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 GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -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 ghcr.io/github/gh-aw-mcpg:v0.2.6'
+
+ mkdir -p /home/runner/.copilot
+ cat << GH_AW_MCP_CONFIG_dd21a14b63d12243_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
+ {
+ "mcpServers": {
+ "github": {
+ "type": "stdio",
+ "container": "ghcr.io/github/github-mcp-server:v0.32.0",
+ "env": {
+ "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": "http",
+ "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
+ "headers": {
+ "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}"
+ },
+ "guard-policies": {
+ "write-sink": {
+ "accept": [
+ "*"
+ ]
+ }
+ }
+ }
+ },
+ "gateway": {
+ "port": $MCP_GATEWAY_PORT,
+ "domain": "${MCP_GATEWAY_DOMAIN}",
+ "apiKey": "${MCP_GATEWAY_API_KEY}",
+ "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
+ }
+ }
+ GH_AW_MCP_CONFIG_dd21a14b63d12243_EOF
+ - name: Download activation artifact
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: activation
+ path: /tmp/gh-aw
+ - name: Clean git credentials
+ continue-on-error: true
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh
+ - name: Execute GitHub Copilot CLI
+ id: agentic_execution
+ # Copilot CLI tool arguments (sorted):
+ timeout-minutes: 5
+ run: |
+ set -o pipefail
+ touch /tmp/gh-aw/agent-step-summary.md
+ # shellcheck disable=SC1003
+ sudo -E awf --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" --allow-host-service-ports "${{ job.services['redis'].ports['6379'] }}" --env-all --allow-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,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,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,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' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.1 --skip-pull --enable-api-proxy \
+ -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ env:
+ COPILOT_AGENT_RUNNER_TYPE: STANDALONE
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }}
+ GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json
+ 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_VERSION: dev
+ GITHUB_API_URL: ${{ github.api_url }}
+ GITHUB_AW: true
+ 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]
+ XDG_CONFIG_HOME: /home/runner
+ - name: Detect inference access error
+ id: detect-inference-error
+ if: always()
+ continue-on-error: true
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git config --global am.keepcr true
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Copy Copilot session state files to logs
+ if: always()
+ continue-on-error: true
+ run: |
+ # Copy Copilot session state files to logs folder for artifact collection
+ # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them
+ SESSION_STATE_DIR="$HOME/.copilot/session-state"
+ LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs"
+
+ if [ -d "$SESSION_STATE_DIR" ]; then
+ echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR"
+ mkdir -p "$LOGS_DIR"
+ cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true
+ echo "Session state files copied successfully"
+ else
+ echo "No session-state directory found at $SESSION_STATE_DIR"
+ fi
+ - 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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/redact_secrets.cjs');
+ await main();
+ env:
+ GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
+ SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,127.0.0.1,::1,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,app.renovatebot.com,appveyor.com,archive.ubuntu.com,azure.archive.ubuntu.com,badgen.net,circleci.com,codacy.com,codeclimate.com,codecov.io,codeload.github.com,coveralls.io,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,deepsource.io,docs.github.com,drone.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,img.shields.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,localhost,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,ppa.launchpad.net,raw.githubusercontent.com,readthedocs.io,readthedocs.org,registry.npmjs.org,renovatebot.com,s.symcb.com,s.symcd.com,security.ubuntu.com,semaphoreci.com,shields.io,snyk.io,sonarcloud.io,sonarqube.com,telemetry.enterprise.githubcopilot.com,travis-ci.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);
+ 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
+ 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/parse_copilot_log.cjs');
+ await main();
+ - name: Parse MCP Gateway logs for step summary
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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/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: |
+ # Fix permissions on firewall logs so they can be uploaded as artifacts
+ # AWF runs with sudo, creating files owned by root
+ sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true
+ # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step)
+ if command -v awf &> /dev/null; then
+ awf logs summary | tee -a "$GITHUB_STEP_SUMMARY"
+ else
+ echo 'AWF binary not installed, skipping firewall log summary'
+ fi
+ - 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ 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-stdio.log
+ /tmp/gh-aw/agent/
+ /tmp/gh-aw/safeoutputs.jsonl
+ /tmp/gh-aw/agent_output.json
+ /tmp/gh-aw/aw-*.patch
+ if-no-files-found: ignore
+ - name: Upload firewall audit logs
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: firewall-audit-logs
+ path: |
+ /tmp/gh-aw/sandbox/firewall/logs/
+ /tmp/gh-aw/sandbox/firewall/audit/
+ 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')
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ concurrency:
+ group: "gh-aw-conclusion-smoke-service-ports"
+ cancel-in-progress: false
+ outputs:
+ 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: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - 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: Process No-Op Messages
+ id: noop
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_NOOP_MAX: "1"
+ GH_AW_WORKFLOW_NAME: "Smoke Service Ports"
+ 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);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs');
+ await main();
+ - name: Record Missing Tool
+ id: missing_tool
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "Smoke Service Ports"
+ 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);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
+ await main();
+ - name: Handle Agent Failure
+ id: handle_agent_failure
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "Smoke Service Ports"
+ 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: "smoke-service-ports"
+ GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
+ GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
+ GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
+ GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
+ GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity...\",\"runSuccess\":\"✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis.\",\"runFailure\":\"❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}\"}"
+ GH_AW_GROUP_REPORTS: "false"
+ GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
+ GH_AW_TIMEOUT_MINUTES: "5"
+ 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);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
+ await main();
+ - name: Handle No-Op Message
+ id: handle_noop_message
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "Smoke Service Ports"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }}
+ GH_AW_NOOP_REPORT_AS_ISSUE: "true"
+ 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);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
+ await main();
+ - name: Update reaction comment with completion status
+ id: conclusion
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ 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_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }}
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_WORKFLOW_NAME: "Smoke Service Ports"
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
+ GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity...\",\"runSuccess\":\"✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis.\",\"runFailure\":\"❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}\"}"
+ 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);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs');
+ await main();
+
+ detection:
+ needs: agent
+ if: always() && needs.agent.result != 'skipped'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
+ detection_success: ${{ steps.detection_conclusion.outputs.success }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - 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"
+ # --- Threat Detection ---
+ - name: Download container images
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.1 ghcr.io/github/gh-aw-firewall/squid:0.25.1
+ - 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 configuration for detection
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ run: |
+ rm -f /tmp/gh-aw/mcp-config/mcp-servers.json
+ rm -f /home/runner/.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
+ cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true
+ 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
+ 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ WORKFLOW_NAME: "Smoke Service Ports"
+ WORKFLOW_DESCRIPTION: "Smoke test to validate --allow-host-service-ports with Redis service container"
+ 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);
+ 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: Install GitHub Copilot CLI
+ run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest
+ env:
+ GH_HOST: github.com
+ - name: Install AWF binary
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.1
+ - name: Execute GitHub Copilot CLI
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ id: detection_agentic_execution
+ # Copilot CLI tool arguments (sorted):
+ timeout-minutes: 20
+ run: |
+ set -o pipefail
+ touch /tmp/gh-aw/agent-step-summary.md
+ # shellcheck disable=SC1003
+ sudo -E awf --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" --env-all --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.1 --skip-pull --enable-api-proxy \
+ -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
+ env:
+ COPILOT_AGENT_RUNNER_TYPE: STANDALONE
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }}
+ GH_AW_PHASE: detection
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_VERSION: dev
+ GITHUB_API_URL: ${{ github.api_url }}
+ GITHUB_AW: true
+ 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]
+ XDG_CONFIG_HOME: /home/runner
+ - name: Upload threat detection log
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ 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()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
+ 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/parse_threat_detection_results.cjs');
+ await main();
+
+ pre_activation:
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
+ matched_command: ''
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ 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/check_membership.cjs');
+ await main();
+
+ safe_outputs:
+ needs:
+ - agent
+ - detection
+ if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ timeout-minutes: 15
+ env:
+ GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-service-ports"
+ GH_AW_ENGINE_ID: "copilot"
+ GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
+ GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity...\",\"runSuccess\":\"✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis.\",\"runFailure\":\"❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}\"}"
+ GH_AW_WORKFLOW_ID: "smoke-service-ports"
+ GH_AW_WORKFLOW_NAME: "Smoke Service Ports"
+ 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 }}
+ comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }}
+ comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }}
+ create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
+ create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
+ 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: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - 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: Configure GH_HOST for enterprise compatibility
+ id: ghes-host-config
+ shell: bash
+ run: |
+ # 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,127.0.0.1,::1,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,app.renovatebot.com,appveyor.com,archive.ubuntu.com,azure.archive.ubuntu.com,badgen.net,circleci.com,codacy.com,codeclimate.com,codecov.io,codeload.github.com,coveralls.io,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,deepsource.io,docs.github.com,drone.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,img.shields.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,localhost,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,ppa.launchpad.net,raw.githubusercontent.com,readthedocs.io,readthedocs.org,registry.npmjs.org,renovatebot.com,s.symcb.com,s.symcd.com,security.ubuntu.com,semaphoreci.com,shields.io,snyk.io,sonarcloud.io,sonarqube.com,telemetry.enterprise.githubcopilot.com,travis-ci.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: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}"
+ 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);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs');
+ await main();
+ - name: Upload Safe Output Items
+ if: always()
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: safe-output-items
+ path: /tmp/gh-aw/safe-output-items.jsonl
+ if-no-files-found: ignore
+
diff --git a/.github/workflows/smoke-service-ports.md b/.github/workflows/smoke-service-ports.md
new file mode 100644
index 00000000000..b0e303b9170
--- /dev/null
+++ b/.github/workflows/smoke-service-ports.md
@@ -0,0 +1,86 @@
+---
+description: Smoke test to validate --allow-host-service-ports with Redis service container
+on:
+ workflow_dispatch:
+ schedule: daily
+ status-comment: true
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+name: Smoke Service Ports
+engine: copilot
+strict: true
+services:
+ redis:
+ image: redis:7
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+network:
+ allowed:
+ - defaults
+ - github
+tools:
+ bash:
+ - "*"
+safe-outputs:
+ allowed-domains: [default-safe-outputs]
+ add-comment:
+ hide-older-comments: true
+ max: 2
+ messages:
+ footer: "> 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}"
+ run-started: "🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity..."
+ run-success: "✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis."
+ run-failure: "❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}"
+timeout-minutes: 5
+---
+
+# Smoke Test: Service Ports (Redis)
+
+**Purpose:** Validate that the `--allow-host-service-ports` feature works end-to-end. The compiler should have automatically detected the Redis service port and configured AWF to allow traffic to it.
+
+**IMPORTANT:** Inside AWF's sandbox, you must connect to services via `host.docker.internal` (not `localhost`). The service containers run on the host, and AWF routes traffic through the host gateway. Since the workflow maps port 6379:6379, port 6379 should work. Keep all outputs concise.
+
+## Required Tests
+
+1. **Redis PING**: Run `redis-cli -h host.docker.internal -p 6379 ping` or `echo PING | nc host.docker.internal 6379` and verify the response contains `PONG`.
+
+2. **Redis SET/GET**: Write a value to Redis and read it back:
+ - `redis-cli -h host.docker.internal -p 6379 SET smoke_test "service-ports-ok"`
+ - `redis-cli -h host.docker.internal -p 6379 GET smoke_test`
+ - Verify the returned value is `service-ports-ok`
+
+3. **Redis INFO**: Run `redis-cli -h host.docker.internal -p 6379 INFO server | head -5` to verify we can query Redis server info.
+
+## Output Requirements
+
+Add a **concise comment** to the pull request (if triggered by PR) with:
+
+- Each test with a pass/fail status
+- Overall status: PASS or FAIL
+- Note whether `redis-cli` was available or if `nc`/netcat was used as fallback
+
+Example:
+```
+## Service Ports Smoke Test (Redis)
+
+| Test | Status |
+|------|--------|
+| Redis PING | ✅ PONG received |
+| Redis SET/GET | ✅ Value round-tripped |
+| Redis INFO | ✅ Server info retrieved |
+
+**Result:** 3/3 tests passed ✅
+```
+
+**Important**: If no action is needed after completing your analysis, you **MUST** call the `noop` safe-output tool with a brief explanation.
+
+```json
+{"noop": {"message": "No action needed: [brief explanation of what was analyzed and why]"}}
+```
diff --git a/pkg/cli/compile_service_ports_integration_test.go b/pkg/cli/compile_service_ports_integration_test.go
new file mode 100644
index 00000000000..5e7ace1a3b5
--- /dev/null
+++ b/pkg/cli/compile_service_ports_integration_test.go
@@ -0,0 +1,153 @@
+//go:build integration
+
+package cli
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestCompileServicePortsWorkflow compiles the canonical test-service-ports.md workflow
+// and verifies that the generated lock file contains --allow-host-service-ports with the
+// correct ${{ job.services[''].ports[''] }} expressions for every service port.
+func TestCompileServicePortsWorkflow(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ srcPath := filepath.Join(projectRoot, "pkg/cli/workflows/test-service-ports.md")
+ dstPath := filepath.Join(setup.workflowsDir, "test-service-ports.md")
+
+ srcContent, err := os.ReadFile(srcPath)
+ if err != nil {
+ t.Fatalf("Failed to read source workflow %s: %v", srcPath, err)
+ }
+ if err := os.WriteFile(dstPath, srcContent, 0644); err != nil {
+ t.Fatalf("Failed to write workflow to test dir: %v", err)
+ }
+
+ cmd := exec.Command(setup.binaryPath, "compile", dstPath)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output))
+ }
+
+ lockFilePath := filepath.Join(setup.workflowsDir, "test-service-ports.lock.yml")
+ lockContent, err := os.ReadFile(lockFilePath)
+ if err != nil {
+ t.Fatalf("Failed to read lock file: %v", err)
+ }
+ lock := string(lockContent)
+
+ // The compiler must emit --allow-host-service-ports
+ if !strings.Contains(lock, "--allow-host-service-ports") {
+ t.Errorf("Lock file missing --allow-host-service-ports\nLock content:\n%s", lock)
+ }
+
+ // Bracket-notation expressions must be present for both services
+ for _, expr := range []string{
+ "job.services['postgres'].ports['5432']",
+ "job.services['redis'].ports['6379']",
+ } {
+ if !strings.Contains(lock, expr) {
+ t.Errorf("Lock file missing expected expression %q\nLock content:\n%s", expr, lock)
+ }
+ }
+
+ t.Logf("test-service-ports.md compiled successfully; --allow-host-service-ports verified")
+}
+
+// TestCompileServicePorts_NoServices verifies that a workflow with no services block
+// compiles without errors and does NOT emit --allow-host-service-ports.
+func TestCompileServicePorts_NoServices(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ testWorkflow := `---
+on:
+ workflow_dispatch:
+permissions:
+ contents: read
+engine: copilot
+---
+
+# No Services Workflow
+
+This workflow has no services block and should not include --allow-host-service-ports.
+`
+ testPath := filepath.Join(setup.workflowsDir, "no-services.md")
+ if err := os.WriteFile(testPath, []byte(testWorkflow), 0644); err != nil {
+ t.Fatalf("Failed to write workflow: %v", err)
+ }
+
+ cmd := exec.Command(setup.binaryPath, "compile", testPath)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output))
+ }
+
+ lockFilePath := filepath.Join(setup.workflowsDir, "no-services.lock.yml")
+ lockContent, err := os.ReadFile(lockFilePath)
+ if err != nil {
+ t.Fatalf("Failed to read lock file: %v", err)
+ }
+
+ if strings.Contains(string(lockContent), "--allow-host-service-ports") {
+ t.Errorf("Lock file should NOT contain --allow-host-service-ports when no services are defined")
+ }
+}
+
+// TestCompileServicePorts_HyphenatedServiceID verifies that service IDs containing
+// hyphens are emitted with bracket notation (not dot notation) in the compiled lock file.
+func TestCompileServicePorts_HyphenatedServiceID(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ testWorkflow := `---
+on:
+ workflow_dispatch:
+permissions:
+ contents: read
+engine: copilot
+services:
+ my-postgres:
+ image: postgres:15
+ ports:
+ - 5432:5432
+---
+
+# Hyphenated Service ID Workflow
+
+Verifies bracket notation for hyphenated service IDs.
+`
+ testPath := filepath.Join(setup.workflowsDir, "hyphenated-service.md")
+ if err := os.WriteFile(testPath, []byte(testWorkflow), 0644); err != nil {
+ t.Fatalf("Failed to write workflow: %v", err)
+ }
+
+ cmd := exec.Command(setup.binaryPath, "compile", testPath)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output))
+ }
+
+ lockFilePath := filepath.Join(setup.workflowsDir, "hyphenated-service.lock.yml")
+ lockContent, err := os.ReadFile(lockFilePath)
+ if err != nil {
+ t.Fatalf("Failed to read lock file: %v", err)
+ }
+ lock := string(lockContent)
+
+ // Must use bracket notation, not dot notation
+ bracketNotation := "job.services['my-postgres'].ports['5432']"
+ dotNotation := "job.services.my-postgres.ports"
+
+ if !strings.Contains(lock, bracketNotation) {
+ t.Errorf("Lock file missing bracket-notation expression %q\nLock content:\n%s", bracketNotation, lock)
+ }
+ if strings.Contains(lock, dotNotation) {
+ t.Errorf("Lock file must NOT use dot notation for hyphenated service IDs; found %q\nLock content:\n%s", dotNotation, lock)
+ }
+}
diff --git a/pkg/cli/workflows/test-service-ports.md b/pkg/cli/workflows/test-service-ports.md
new file mode 100644
index 00000000000..1ac3a9f4741
--- /dev/null
+++ b/pkg/cli/workflows/test-service-ports.md
@@ -0,0 +1,24 @@
+---
+on:
+ workflow_dispatch:
+permissions:
+ contents: read
+engine: copilot
+services:
+ postgres:
+ image: postgres:15
+ ports:
+ - 5432:5432
+ redis:
+ image: redis:7
+ ports:
+ - 6379:6379
+---
+
+# Test Service Ports
+
+This workflow tests that the compiler automatically generates `--allow-host-service-ports`
+from `services:` port mappings.
+
+Expected: the compiled lock file includes `--allow-host-service-ports` with expressions for
+both PostgreSQL (port 5432) and Redis (port 6379).
diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go
index c9239db376b..22aca11672e 100644
--- a/pkg/workflow/awf_helpers.go
+++ b/pkg/workflow/awf_helpers.go
@@ -97,6 +97,16 @@ func BuildAWFCommand(config AWFCommandConfig) string {
ghAwDir, ghAwDir, ghAwDir, ghAwDir,
)
+ // Add --allow-host-service-ports for services with port mappings.
+ // This is appended as a raw (expandable) arg because the value contains
+ // ${{ job.services..ports[''] }} expressions that include single quotes.
+ // These expressions are resolved by the GitHub Actions runner before shell execution,
+ // so they must not be shell-escaped.
+ if config.WorkflowData != nil && config.WorkflowData.ServicePortExpressions != "" {
+ expandableArgs += fmt.Sprintf(` --allow-host-service-ports "%s"`, config.WorkflowData.ServicePortExpressions)
+ awfHelpersLog.Printf("Added --allow-host-service-ports with %s", config.WorkflowData.ServicePortExpressions)
+ }
+
// Wrap engine command in shell (command already includes any internal setup like npm PATH)
shellWrappedCommand := WrapCommandInShell(config.EngineCommand)
diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go
index d496fe32513..7b4c897bcfd 100644
--- a/pkg/workflow/compiler_orchestrator_workflow.go
+++ b/pkg/workflow/compiler_orchestrator_workflow.go
@@ -4,8 +4,10 @@ import (
"encoding/json"
"fmt"
"maps"
+ "os"
"strings"
+ "github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/parser"
"github.com/goccy/go-yaml"
@@ -529,6 +531,20 @@ func (c *Compiler) processAndMergeServices(frontmatter map[string]any, workflowD
}
}
}
+
+ // Extract service port expressions for AWF --allow-host-service-ports
+ if workflowData.Services != "" {
+ expressions, warnings := ExtractServicePortExpressions(workflowData.Services)
+ workflowData.ServicePortExpressions = expressions
+ for _, w := range warnings {
+ orchestratorWorkflowLog.Printf("Warning: %s", w)
+ fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w))
+ c.IncrementWarningCount()
+ }
+ if expressions != "" {
+ orchestratorWorkflowLog.Printf("Extracted service port expressions: %s", expressions)
+ }
+ }
}
// mergeJobsFromYAMLImports merges jobs from imported YAML workflows with main workflow jobs
diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go
index b41412bcb50..27e5b7a2b21 100644
--- a/pkg/workflow/compiler_types.go
+++ b/pkg/workflow/compiler_types.go
@@ -436,6 +436,7 @@ type WorkflowData struct {
IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run)
UpdateCheckDisabled bool // true when check-for-updates: false is set in frontmatter (disables version check step in activation job)
EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps
+ ServicePortExpressions string // comma-separated ${{ job.services[''].ports[''] }} expressions for AWF --allow-host-service-ports
}
// BaseSafeOutputConfig holds common configuration fields for all safe output types
diff --git a/pkg/workflow/service_ports.go b/pkg/workflow/service_ports.go
new file mode 100644
index 00000000000..b4ac5995dfb
--- /dev/null
+++ b/pkg/workflow/service_ports.go
@@ -0,0 +1,256 @@
+// This file provides helper functions for extracting service port mappings from
+// GitHub Actions services: configuration and generating ${{ job.services[''].ports[''] }}
+// expressions for AWF's --allow-host-service-ports flag.
+//
+// When a workflow uses GitHub Actions services: with port mappings (e.g., PostgreSQL, Redis),
+// the compiled workflow runs the agent inside AWF's isolated network. The agent cannot reach
+// service containers without explicit --allow-host-service-ports configuration. This file
+// automatically detects service ports and generates the necessary expressions.
+
+package workflow
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/github/gh-aw/pkg/logger"
+ "github.com/goccy/go-yaml"
+)
+
+var servicePortsLog = logger.New("workflow:service_ports")
+
+// maxPortRangeExpansion is the maximum number of ports to expand from a range specification.
+// This prevents accidentally generating thousands of expressions from a large port range.
+const maxPortRangeExpansion = 32
+
+// minPort and maxPort define the valid TCP/UDP port range.
+const (
+ minPort = 1
+ maxPort = 65535
+)
+
+// servicesYAMLWrapper is the top-level YAML wrapper for a services: block.
+// It provides typed access to the service container map while the YAML is parsed
+// via goccy/go-yaml with field-level annotations.
+type servicesYAMLWrapper struct {
+ Services map[string]*serviceContainerConfig `yaml:"services"`
+}
+
+// serviceContainerConfig represents a single GitHub Actions service container.
+// Only the Ports field is consumed for port-expression generation; all other
+// container fields (image, env, options, volumes, …) are intentionally omitted.
+//
+// Ports is declared as any because GitHub Actions allows the ports list to contain
+// both string values ("5432:5432") and bare integers (5432), and the YAML may also
+// omit the field entirely (nil) or supply a non-list scalar, which triggers a
+// compile-time warning.
+type serviceContainerConfig struct {
+ Ports any `yaml:"ports"`
+}
+
+// ExtractServicePortExpressions parses the services: YAML string from WorkflowData.Services
+// and returns a comma-separated string of ${{ job.services[''].ports[''] }} expressions
+// for all TCP port mappings found.
+//
+// The returned string is suitable for passing as --allow-host-service-ports to AWF.
+// Returns empty string if no services or no port mappings are found.
+//
+// Bracket notation (job.services['id']) is used for all service IDs to correctly handle
+// IDs containing hyphens, digits-first names, or other characters invalid in dot-notation.
+//
+// Parameters:
+// - servicesYAML: Raw YAML string from WorkflowData.Services (includes "services:" wrapper)
+//
+// Returns:
+// - expressions: Comma-separated ${{ }} expressions for all service ports
+// - warnings: Any warnings generated during parsing (e.g., UDP ports, services without ports)
+func ExtractServicePortExpressions(servicesYAML string) (string, []string) {
+ if servicesYAML == "" {
+ return "", nil
+ }
+
+ servicePortsLog.Print("Extracting service port expressions from services YAML")
+
+ // Parse the services YAML into typed structs so field access is explicit
+ // and does not rely on map[string]any type assertions.
+ var wrapper servicesYAMLWrapper
+ if err := yaml.Unmarshal([]byte(servicesYAML), &wrapper); err != nil {
+ servicePortsLog.Printf("Failed to parse services YAML: %v", err)
+ return "", nil
+ }
+
+ if wrapper.Services == nil {
+ servicePortsLog.Print("No services map found in YAML")
+ return "", nil
+ }
+
+ var expressions []string
+ var warnings []string
+
+ // Sort service IDs for deterministic output
+ serviceIDs := make([]string, 0, len(wrapper.Services))
+ for id := range wrapper.Services {
+ serviceIDs = append(serviceIDs, id)
+ }
+ sort.Strings(serviceIDs)
+
+ for _, serviceID := range serviceIDs {
+ svc := wrapper.Services[serviceID]
+ if svc == nil {
+ servicePortsLog.Printf("Service %s is nil, skipping", serviceID)
+ continue
+ }
+
+ if svc.Ports == nil {
+ warnings = append(warnings, fmt.Sprintf("service %q has no ports mapping; it will not be reachable from the AWF sandbox", serviceID))
+ servicePortsLog.Printf("Service %s has no ports, skipping", serviceID)
+ continue
+ }
+
+ portsList, ok := svc.Ports.([]any)
+ if !ok {
+ servicePortsLog.Printf("Service %s ports is not a list, skipping", serviceID)
+ warnings = append(warnings, fmt.Sprintf("service %q has an invalid ports mapping (expected a list); it will not be reachable from the AWF sandbox", serviceID))
+ continue
+ }
+
+ for _, portSpec := range portsList {
+ containerPorts, portWarnings := parsePortSpec(portSpec)
+ for _, w := range portWarnings {
+ warnings = append(warnings, fmt.Sprintf("service %q: %s", serviceID, w))
+ }
+ for _, cp := range containerPorts {
+ escapedServiceID := strings.ReplaceAll(serviceID, "'", "''")
+ expr := fmt.Sprintf("${{ job.services['%s'].ports['%d'] }}", escapedServiceID, cp)
+ expressions = append(expressions, expr)
+ }
+ }
+ }
+
+ if len(expressions) == 0 {
+ servicePortsLog.Print("No service port expressions generated")
+ return "", warnings
+ }
+
+ result := strings.Join(expressions, ",")
+ servicePortsLog.Printf("Generated %d service port expressions", len(expressions))
+ return result, warnings
+}
+
+// parsePortSpec parses a single port specification and returns the container port(s).
+// Supports formats:
+// - "5432:5432" (host:container)
+// - "5432" (container only, dynamic host port)
+// - "49152:5432" (remapped host port)
+// - "5432/tcp" (explicit TCP)
+// - "5432/udp" (skipped with warning)
+// - "6000-6010:6000-6010" (range)
+// - 5432 (integer)
+//
+// Returns container port numbers and any warnings.
+func parsePortSpec(spec any) ([]int, []string) {
+ var portStr string
+ switch v := spec.(type) {
+ case int:
+ if v < minPort || v > maxPort {
+ return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", v, minPort, maxPort)}
+ }
+ return []int{v}, nil
+ case uint64:
+ // goccy/go-yaml decodes unquoted integers as uint64
+ p := int(v)
+ if p < minPort || p > maxPort {
+ return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", p, minPort, maxPort)}
+ }
+ return []int{p}, nil
+ case int64:
+ p := int(v)
+ if p < minPort || p > maxPort {
+ return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", p, minPort, maxPort)}
+ }
+ return []int{p}, nil
+ case float64:
+ // Some YAML libraries parse unquoted numbers as float64
+ p := int(v)
+ if float64(p) != v {
+ return nil, []string{fmt.Sprintf("port %v is not an integer", v)}
+ }
+ if p < minPort || p > maxPort {
+ return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", p, minPort, maxPort)}
+ }
+ return []int{p}, nil
+ case string:
+ portStr = v
+ default:
+ return nil, []string{fmt.Sprintf("unsupported port spec type %T: %v", spec, spec)}
+ }
+
+ portStr = strings.TrimSpace(portStr)
+ if portStr == "" {
+ return nil, nil
+ }
+
+ // Check for protocol suffix
+ protocol := "tcp"
+ if idx := strings.LastIndex(portStr, "/"); idx != -1 {
+ protocol = strings.ToLower(portStr[idx+1:])
+ portStr = portStr[:idx]
+ }
+
+ if protocol == "udp" {
+ return nil, []string{fmt.Sprintf("UDP port %q skipped; AWF only supports TCP", portStr)}
+ }
+ if protocol != "tcp" {
+ return nil, []string{fmt.Sprintf("unsupported protocol %q for port %q; AWF only supports TCP", protocol, portStr)}
+ }
+
+ // Split host:container
+ var containerPart string
+ if _, after, found := strings.Cut(portStr, ":"); found {
+ containerPart = after
+ } else {
+ containerPart = portStr
+ }
+
+ // Check for port range (e.g., "6000-6010")
+ if startStr, endStr, found := strings.Cut(containerPart, "-"); found {
+ start, err1 := strconv.Atoi(startStr)
+ end, err2 := strconv.Atoi(endStr)
+ if err1 != nil || err2 != nil {
+ return nil, []string{fmt.Sprintf("invalid port range %q", containerPart)}
+ }
+
+ if end < start {
+ return nil, []string{fmt.Sprintf("invalid port range %q: end < start", containerPart)}
+ }
+
+ count := end - start + 1
+ if count > maxPortRangeExpansion {
+ return nil, []string{fmt.Sprintf("port range %q expands to %d ports, exceeding cap of %d", containerPart, count, maxPortRangeExpansion)}
+ }
+
+ if start < minPort || end > maxPort {
+ return nil, []string{fmt.Sprintf("port range %q contains ports outside valid range %d-%d", containerPart, minPort, maxPort)}
+ }
+
+ ports := make([]int, 0, count)
+ for p := start; p <= end; p++ {
+ ports = append(ports, p)
+ }
+ return ports, nil
+ }
+
+ // Single port
+ port, err := strconv.Atoi(containerPart)
+ if err != nil {
+ return nil, []string{fmt.Sprintf("invalid port number %q", containerPart)}
+ }
+
+ if port < minPort || port > maxPort {
+ return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", port, minPort, maxPort)}
+ }
+
+ return []int{port}, nil
+}
diff --git a/pkg/workflow/service_ports_test.go b/pkg/workflow/service_ports_test.go
new file mode 100644
index 00000000000..f78d2a0afe8
--- /dev/null
+++ b/pkg/workflow/service_ports_test.go
@@ -0,0 +1,348 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePortSpec(t *testing.T) {
+ tests := []struct {
+ name string
+ spec any
+ expectedPorts []int
+ warnContains string
+ }{
+ {
+ name: "explicit host:container mapping",
+ spec: "5432:5432",
+ expectedPorts: []int{5432},
+ },
+ {
+ name: "container port only (dynamic host)",
+ spec: "5432",
+ expectedPorts: []int{5432},
+ },
+ {
+ name: "remapped host port",
+ spec: "49152:5432",
+ expectedPorts: []int{5432},
+ },
+ {
+ name: "explicit TCP protocol",
+ spec: "5432/tcp",
+ expectedPorts: []int{5432},
+ },
+ {
+ name: "UDP port skipped",
+ spec: "5432/udp",
+ expectedPorts: nil,
+ warnContains: "UDP",
+ },
+ {
+ name: "integer port spec",
+ spec: 5432,
+ expectedPorts: []int{5432},
+ },
+ {
+ name: "float64 port spec (YAML parsing)",
+ spec: float64(5432),
+ expectedPorts: []int{5432},
+ },
+ {
+ name: "port range",
+ spec: "6000-6002:6000-6002",
+ expectedPorts: []int{6000, 6001, 6002},
+ },
+ {
+ name: "port range container only",
+ spec: "6000-6002",
+ expectedPorts: []int{6000, 6001, 6002},
+ },
+ {
+ name: "host:container with TCP suffix",
+ spec: "5432:5432/tcp",
+ expectedPorts: []int{5432},
+ },
+ {
+ name: "invalid port number",
+ spec: "abc",
+ expectedPorts: nil,
+ warnContains: "invalid port number",
+ },
+ {
+ name: "invalid port range (end < start)",
+ spec: "6010-6000",
+ expectedPorts: nil,
+ warnContains: "end < start",
+ },
+ {
+ name: "port range exceeding cap",
+ spec: "1000-2000",
+ expectedPorts: nil,
+ warnContains: "exceeding cap",
+ },
+ {
+ name: "unsupported type",
+ spec: true,
+ expectedPorts: nil,
+ warnContains: "unsupported port spec type",
+ },
+ {
+ name: "empty string",
+ spec: "",
+ expectedPorts: nil,
+ },
+ {
+ name: "port zero is out of range",
+ spec: "0",
+ expectedPorts: nil,
+ warnContains: "outside valid range",
+ },
+ {
+ name: "port above 65535",
+ spec: "70000",
+ expectedPorts: nil,
+ warnContains: "outside valid range",
+ },
+ {
+ name: "integer port zero is out of range",
+ spec: 0,
+ expectedPorts: nil,
+ warnContains: "outside valid range",
+ },
+ {
+ name: "unknown protocol skipped",
+ spec: "5432/sctp",
+ expectedPorts: nil,
+ warnContains: "unsupported protocol",
+ },
+ {
+ name: "float64 non-integer rejected",
+ spec: float64(5432.5),
+ expectedPorts: nil,
+ warnContains: "not an integer",
+ },
+ {
+ name: "port range with out-of-range values",
+ spec: "65530-65540",
+ expectedPorts: nil,
+ warnContains: "outside valid range",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ports, warnings := parsePortSpec(tt.spec)
+ assert.Equal(t, tt.expectedPorts, ports)
+
+ if tt.warnContains != "" {
+ require.NotEmpty(t, warnings, "expected a warning containing %q", tt.warnContains)
+ found := false
+ for _, w := range warnings {
+ if strings.Contains(w, tt.warnContains) {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "expected warning containing %q, got %v", tt.warnContains, warnings)
+ }
+ })
+ }
+}
+
+func TestExtractServicePortExpressions(t *testing.T) {
+ tests := []struct {
+ name string
+ servicesYAML string
+ expectedResult string
+ expectedWarnings []string
+ }{
+ {
+ name: "empty services YAML",
+ servicesYAML: "",
+ expectedResult: "",
+ },
+ {
+ name: "single service with single port",
+ servicesYAML: `services:
+ postgres:
+ image: postgres:15
+ ports:
+ - 5432:5432
+`,
+ expectedResult: "${{ job.services['postgres'].ports['5432'] }}",
+ },
+ {
+ name: "multiple services with ports",
+ servicesYAML: `services:
+ postgres:
+ image: postgres:15
+ ports:
+ - 5432:5432
+ redis:
+ image: redis:7
+ ports:
+ - 6379:6379
+`,
+ expectedResult: "${{ job.services['postgres'].ports['5432'] }},${{ job.services['redis'].ports['6379'] }}",
+ },
+ {
+ name: "service with multiple ports",
+ servicesYAML: `services:
+ mydb:
+ image: mydb:latest
+ ports:
+ - 5432:5432
+ - 8080:8080
+`,
+ expectedResult: "${{ job.services['mydb'].ports['5432'] }},${{ job.services['mydb'].ports['8080'] }}",
+ },
+ {
+ name: "service without ports emits warning",
+ servicesYAML: `services:
+ postgres:
+ image: postgres:15
+`,
+ expectedResult: "",
+ expectedWarnings: []string{"service \"postgres\" has no ports mapping"},
+ },
+ {
+ name: "mixed services: some with ports, some without",
+ servicesYAML: `services:
+ postgres:
+ image: postgres:15
+ ports:
+ - 5432:5432
+ redis:
+ image: redis:7
+`,
+ expectedResult: "${{ job.services['postgres'].ports['5432'] }}",
+ expectedWarnings: []string{"service \"redis\" has no ports mapping"},
+ },
+ {
+ name: "UDP port skipped with warning",
+ servicesYAML: `services:
+ myservice:
+ image: myservice:latest
+ ports:
+ - 5432:5432/udp
+`,
+ expectedResult: "",
+ expectedWarnings: []string{"UDP"},
+ },
+ {
+ name: "port range expansion",
+ servicesYAML: `services:
+ myservice:
+ image: myservice:latest
+ ports:
+ - 6000-6002:6000-6002
+`,
+ expectedResult: "${{ job.services['myservice'].ports['6000'] }},${{ job.services['myservice'].ports['6001'] }},${{ job.services['myservice'].ports['6002'] }}",
+ },
+ {
+ name: "dynamic host port (container port only)",
+ servicesYAML: `services:
+ postgres:
+ image: postgres:15
+ ports:
+ - 5432
+`,
+ expectedResult: "${{ job.services['postgres'].ports['5432'] }}",
+ },
+ {
+ name: "remapped host port uses container port in expression",
+ servicesYAML: `services:
+ postgres:
+ image: postgres:15
+ ports:
+ - 49152:5432
+`,
+ expectedResult: "${{ job.services['postgres'].ports['5432'] }}",
+ },
+ {
+ name: "invalid YAML returns empty",
+ servicesYAML: "not: valid: yaml: [",
+ expectedResult: "",
+ },
+ {
+ name: "integer port values",
+ servicesYAML: `services:
+ postgres:
+ image: postgres:15
+ ports:
+ - 5432
+`,
+ expectedResult: "${{ job.services['postgres'].ports['5432'] }}",
+ },
+ {
+ name: "hyphenated service ID",
+ servicesYAML: `services:
+ my-postgres:
+ image: postgres:15
+ ports:
+ - 5432:5432
+`,
+ expectedResult: "${{ job.services['my-postgres'].ports['5432'] }}",
+ },
+ {
+ name: "invalid ports format (not a list) emits warning",
+ servicesYAML: `services:
+ postgres:
+ image: postgres:15
+ ports: 5432
+`,
+ expectedResult: "",
+ expectedWarnings: []string{"invalid ports mapping"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, warnings := ExtractServicePortExpressions(tt.servicesYAML)
+ assert.Equal(t, tt.expectedResult, result)
+
+ if tt.expectedWarnings != nil {
+ for _, expectedWarning := range tt.expectedWarnings {
+ found := false
+ for _, w := range warnings {
+ if strings.Contains(w, expectedWarning) {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "expected warning containing %q, got %v", expectedWarning, warnings)
+ }
+ }
+ })
+ }
+}
+
+func TestExtractServicePortExpressions_DeterministicOrder(t *testing.T) {
+ // Run multiple times to verify deterministic ordering
+ servicesYAML := `services:
+ zeta:
+ image: zeta:latest
+ ports:
+ - 1111:1111
+ alpha:
+ image: alpha:latest
+ ports:
+ - 2222:2222
+ middle:
+ image: middle:latest
+ ports:
+ - 3333:3333
+`
+ expected := "${{ job.services['alpha'].ports['2222'] }},${{ job.services['middle'].ports['3333'] }},${{ job.services['zeta'].ports['1111'] }}"
+
+ for i := range 10 {
+ result, _ := ExtractServicePortExpressions(servicesYAML)
+ assert.Equal(t, expected, result, "iteration %d: order should be deterministic (alphabetical by service ID)", i)
+ }
+}