diff --git a/.github/workflows/daily-fact.lock.yml b/.github/workflows/daily-fact.lock.yml index 5724aefd8fd..696981bde35 100644 --- a/.github/workflows/daily-fact.lock.yml +++ b/.github/workflows/daily-fact.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"36d37a405b99aa890e631983125c296a231d492607435c20e8632d45621f8ecd","strict":true,"agent_id":"codex","agent_model":"gpt-5.4-mini"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"4d7cf7739698722a561870045505a39cb9b1f80ad113ae5ca1df484b1bb3ccbd","strict":true,"agent_id":"codex","agent_model":"gpt-5.4-mini"} # gh-aw-manifest: {"version":1,"secrets":["CODEX_API_KEY","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_ENDPOINT","GH_AW_OTEL_HEADERS","GITHUB_TOKEN","OPENAI_API_KEY"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"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":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"a309ff8b426b58ec0e2a45f0f869d46889d02405","version":"v6.2.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.43"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.43"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.43"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.43"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.6","digest":"sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c"},{"image":"ghcr.io/github/github-mcp-server:v1.0.3","digest":"sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959","pinned_image":"ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) @@ -229,7 +229,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl GH_AW_EXPERIMENTS_REASONING_DEPTH: ${{ steps.pick-experiment.outputs.reasoning_depth }} - GH_AW_EXPR_DD64DF46: ${{ steps.pick-experiment.outputs.reasoning_depth == "multi_candidate" }} + GH_AW_EXPR_CBA23170: ${{ steps.pick-experiment.outputs.reasoning_depth == 'multi_candidate' }} 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 }} @@ -242,21 +242,21 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_6f6003e03d2c7d0e_EOF' + cat << 'GH_AW_PROMPT_10c047efb98cf84f_EOF' - GH_AW_PROMPT_6f6003e03d2c7d0e_EOF + GH_AW_PROMPT_10c047efb98cf84f_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/cache_memory_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_6f6003e03d2c7d0e_EOF' + cat << 'GH_AW_PROMPT_10c047efb98cf84f_EOF' Tools: add_comment, missing_tool, missing_data, noop - GH_AW_PROMPT_6f6003e03d2c7d0e_EOF + GH_AW_PROMPT_10c047efb98cf84f_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_6f6003e03d2c7d0e_EOF' + cat << 'GH_AW_PROMPT_10c047efb98cf84f_EOF' The following GitHub context information is available for this workflow: {{#if __GH_AW_GITHUB_ACTOR__ }} @@ -285,9 +285,9 @@ jobs: {{/if}} - GH_AW_PROMPT_6f6003e03d2c7d0e_EOF + GH_AW_PROMPT_10c047efb98cf84f_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/cli_proxy_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_6f6003e03d2c7d0e_EOF' + cat << 'GH_AW_PROMPT_10c047efb98cf84f_EOF' @@ -334,7 +334,7 @@ jobs: ## Guidelines - **Check memory first**: Skip any PR, issue, or release that already appears in the palace results from Step 0 - {{#if __GH_AW_EXPR_DD64DF46__ }} + {{#if __GH_AW_EXPR_CBA23170__ }} - **Multi-candidate deliberation**: Before writing, identify exactly **3 distinct candidate facts** (one PR, one issue or release, one contributor or pattern). For each candidate write one sentence on why it is novel today. Then score each candidate 1–5 on: (a) novelty vs palace memory, (b) intrinsic poetic potential. Select the highest-scoring candidate and write the verse for that one only. {{else}} - **Favor recent updates** but include variety - pick something interesting, not just the most recent @@ -390,7 +390,7 @@ jobs: {{#runtime-import shared/noop-reminder.md}} - GH_AW_PROMPT_6f6003e03d2c7d0e_EOF + GH_AW_PROMPT_10c047efb98cf84f_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -398,7 +398,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_ENGINE_ID: "codex" GH_AW_EXPERIMENTS_REASONING_DEPTH: ${{ steps.pick-experiment.outputs.reasoning_depth }} - GH_AW_EXPR_DD64DF46: ${{ steps.pick-experiment.outputs.reasoning_depth == "multi_candidate" }} + GH_AW_EXPR_CBA23170: ${{ steps.pick-experiment.outputs.reasoning_depth == 'multi_candidate' }} GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} with: script: | @@ -414,7 +414,7 @@ jobs: GH_AW_CACHE_DESCRIPTION: '' GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/' GH_AW_EXPERIMENTS_REASONING_DEPTH: ${{ steps.pick-experiment.outputs.reasoning_depth }} - GH_AW_EXPR_DD64DF46: ${{ steps.pick-experiment.outputs.reasoning_depth == "multi_candidate" }} + GH_AW_EXPR_CBA23170: ${{ steps.pick-experiment.outputs.reasoning_depth == 'multi_candidate' }} 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 }} @@ -439,7 +439,7 @@ jobs: GH_AW_CACHE_DESCRIPTION: process.env.GH_AW_CACHE_DESCRIPTION, GH_AW_CACHE_DIR: process.env.GH_AW_CACHE_DIR, GH_AW_EXPERIMENTS_REASONING_DEPTH: process.env.GH_AW_EXPERIMENTS_REASONING_DEPTH, - GH_AW_EXPR_DD64DF46: process.env.GH_AW_EXPR_DD64DF46, + GH_AW_EXPR_CBA23170: process.env.GH_AW_EXPR_CBA23170, 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, @@ -645,9 +645,9 @@ jobs: 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_d1209a06df4079df_EOF' + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_65f0ad3627110281_EOF' {"add_comment":{"max":1,"target":"4750"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} - GH_AW_SAFE_OUTPUTS_CONFIG_d1209a06df4079df_EOF + GH_AW_SAFE_OUTPUTS_CONFIG_65f0ad3627110281_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -839,7 +839,7 @@ jobs: DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --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_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 -e GITHUB_AW_OTEL_TRACE_ID -e GITHUB_AW_OTEL_PARENT_SPAN_ID -e CODEX_HOME -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.3.6' - cat > "${RUNNER_TEMP}/gh-aw/mcp-config/config.toml" << GH_AW_MCP_CONFIG_ece71de4bad38321_EOF + cat > "${RUNNER_TEMP}/gh-aw/mcp-config/config.toml" << GH_AW_MCP_CONFIG_c987b61d13852d08_EOF [history] persistence = "none" @@ -866,11 +866,11 @@ jobs: [mcp_servers.safeoutputs."guard-policies".write-sink] accept = ["*"] - GH_AW_MCP_CONFIG_ece71de4bad38321_EOF + GH_AW_MCP_CONFIG_c987b61d13852d08_EOF # Generate JSON config for MCP gateway GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_ece71de4bad38321_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_c987b61d13852d08_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "mempalace": { @@ -933,11 +933,11 @@ jobs: } } } - GH_AW_MCP_CONFIG_ece71de4bad38321_EOF + GH_AW_MCP_CONFIG_c987b61d13852d08_EOF # Sync converter output to writable CODEX_HOME for Codex mkdir -p /tmp/gh-aw/mcp-config - cat > "/tmp/gh-aw/mcp-config/config.toml" << GH_AW_CODEX_SHELL_POLICY_50e3008d43a21862_EOF + cat > "/tmp/gh-aw/mcp-config/config.toml" << GH_AW_CODEX_SHELL_POLICY_1f630f4fb2369cb5_EOF model_provider = "openai-proxy" @@ -949,7 +949,7 @@ jobs: [shell_environment_policy] inherit = "core" include_only = ["CODEX_API_KEY", "GH_AW_ASSETS_ALLOWED_EXTS", "GH_AW_ASSETS_BRANCH", "GH_AW_ASSETS_MAX_SIZE_KB", "GH_AW_SAFE_OUTPUTS", "GITHUB_REPOSITORY", "GITHUB_SERVER_URL", "HOME", "OPENAI_API_KEY", "PATH"] - GH_AW_CODEX_SHELL_POLICY_50e3008d43a21862_EOF + GH_AW_CODEX_SHELL_POLICY_1f630f4fb2369cb5_EOF awk ' BEGIN { skip_openai_proxy = 0 } /^[[:space:]]*model_provider[[:space:]]*=/ { next } @@ -1516,18 +1516,18 @@ jobs: DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --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_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 CODEX_HOME -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.3.6' - cat > "${RUNNER_TEMP}/gh-aw/mcp-config/config.toml" << GH_AW_MCP_CONFIG_14dbc03d40ba732e_EOF + cat > "${RUNNER_TEMP}/gh-aw/mcp-config/config.toml" << GH_AW_MCP_CONFIG_6abeefb5f2f72534_EOF [history] persistence = "none" [shell_environment_policy] inherit = "core" include_only = ["CODEX_API_KEY", "HOME", "OPENAI_API_KEY", "PATH"] - GH_AW_MCP_CONFIG_14dbc03d40ba732e_EOF + GH_AW_MCP_CONFIG_6abeefb5f2f72534_EOF # Generate JSON config for MCP gateway GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_d930ecb7f080feb5_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_ab2f2995c9ec081d_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { }, @@ -1538,11 +1538,11 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_d930ecb7f080feb5_EOF + GH_AW_MCP_CONFIG_ab2f2995c9ec081d_EOF # Sync converter output to writable CODEX_HOME for Codex mkdir -p /tmp/gh-aw/mcp-config - cat > "/tmp/gh-aw/mcp-config/config.toml" << GH_AW_CODEX_SHELL_POLICY_9d95872585821d9c_EOF + cat > "/tmp/gh-aw/mcp-config/config.toml" << GH_AW_CODEX_SHELL_POLICY_f08b56c0f509b97a_EOF model_provider = "openai-proxy" [model_providers.openai-proxy] name = "OpenAI AWF proxy" @@ -1552,7 +1552,7 @@ jobs: [shell_environment_policy] inherit = "core" include_only = ["CODEX_API_KEY", "HOME", "OPENAI_API_KEY", "PATH"] - GH_AW_CODEX_SHELL_POLICY_9d95872585821d9c_EOF + GH_AW_CODEX_SHELL_POLICY_f08b56c0f509b97a_EOF awk ' BEGIN { skip_openai_proxy = 0 } /^[[:space:]]*model_provider[[:space:]]*=/ { next } diff --git a/.github/workflows/daily-fact.md b/.github/workflows/daily-fact.md index 7574d87991c..320f5892276 100644 --- a/.github/workflows/daily-fact.md +++ b/.github/workflows/daily-fact.md @@ -99,7 +99,7 @@ Mine recent activity from the repository to find interesting facts. Focus on: ## Guidelines - **Check memory first**: Skip any PR, issue, or release that already appears in the palace results from Step 0 -{{#if experiments.reasoning_depth == "multi_candidate"}} +{{#if experiments.reasoning_depth == 'multi_candidate'}} - **Multi-candidate deliberation**: Before writing, identify exactly **3 distinct candidate facts** (one PR, one issue or release, one contributor or pattern). For each candidate write one sentence on why it is novel today. Then score each candidate 1–5 on: (a) novelty vs palace memory, (b) intrinsic poetic potential. Select the highest-scoring candidate and write the verse for that one only. {{else}} - **Favor recent updates** but include variety - pick something interesting, not just the most recent diff --git a/pkg/workflow/compiler_orchestrator_frontmatter.go b/pkg/workflow/compiler_orchestrator_frontmatter.go index d77d58841a3..8450c2273f9 100644 --- a/pkg/workflow/compiler_orchestrator_frontmatter.go +++ b/pkg/workflow/compiler_orchestrator_frontmatter.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/parser" ) @@ -141,6 +142,15 @@ func (c *Compiler) parseFrontmatterSection(markdownPath string) (*frontmatterPar return nil, fmt.Errorf("template condition validation failed: %w", err) } + // Warn when experiment comparison expressions use double-quoted string literals. + // GitHub Actions expression syntax only supports single-quoted string literals, so + // the compiler converts double quotes to single quotes automatically — but authors + // should fix the source to use single quotes to keep it consistent with the output. + for _, w := range detectDoubleQuotedExperimentComparisons(result.Markdown) { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w)) + c.IncrementWarningCount() + } + log.Printf("Frontmatter: %d chars, Markdown: %d chars", len(result.Frontmatter), len(result.Markdown)) return &frontmatterParseResult{ diff --git a/pkg/workflow/compiler_template_validation_test.go b/pkg/workflow/compiler_template_validation_test.go index 94601b699c2..5de3d4c2d7c 100644 --- a/pkg/workflow/compiler_template_validation_test.go +++ b/pkg/workflow/compiler_template_validation_test.go @@ -150,7 +150,7 @@ permissions: strict: false --- -{{#if experiments.prompt_style == "detailed"}} +{{#if experiments.prompt_style == 'detailed'}} detailed prompt {{else}} concise prompt diff --git a/pkg/workflow/expression_extraction.go b/pkg/workflow/expression_extraction.go index 00b92265f1c..b43393e661b 100644 --- a/pkg/workflow/expression_extraction.go +++ b/pkg/workflow/expression_extraction.go @@ -201,11 +201,11 @@ var experimentNameRegex = regexp.MustCompile(`^experiments\.([a-zA-Z_][a-zA-Z0-9 // experimentComparisonRegex matches experiments. followed by a comparison operator and // a quoted string value, e.g. `experiments.prompt_style == "concise"` or -// `experiments.prompt_style !== "detailed"`. The value must be enclosed in double quotes -// with no embedded quotes. It captures: +// `experiments.prompt_style !== "detailed"`. The value may be enclosed in double quotes or +// single quotes, with no embedded quotes of the same kind. It captures: // - group 1: the experiment name // - group 2: the remainder of the expression (operator + quoted value), verbatim -var experimentComparisonRegex = regexp.MustCompile(`^experiments\.([a-zA-Z_][a-zA-Z0-9_]*)([ \t]*(?:!==?|===?)[ \t]*"[^"]*"[ \t]*)$`) +var experimentComparisonRegex = regexp.MustCompile(`^experiments\.([a-zA-Z_][a-zA-Z0-9_]*)([ \t]*(?:!==?|===?)[ \t]*(?:"[^"]*"|'[^']*')[ \t]*)$`) // ExperimentEnvVarName returns the env-var name used for the given experiment. // The name is uppercased; hyphens are converted to underscores; all other characters @@ -221,7 +221,10 @@ func ExperimentEnvVarName(experimentName string) string { // placeholder substitution step reads the value from the pick_experiment step output. // // Simple form: experiments.name → steps.pick-experiment.outputs.name -// Comparison form: experiments.name == "v" → steps.pick-experiment.outputs.name == "v" +// Comparison form: experiments.name == "v" → steps.pick-experiment.outputs.name == 'v' +// +// Double quotes in the comparison value are converted to single quotes because GitHub +// Actions expression syntax only supports single-quoted string literals. // // This is used for ${{ experiments.name }} and ${{ experiments.name == "value" }} expressions // that appear directly in the prompt body (mostly relevant in inline mode; in runtime-import @@ -236,7 +239,13 @@ func transformExperimentsExpression(expr string) string { return "steps.pick-experiment.outputs." + m[1] } if m := experimentComparisonRegex.FindStringSubmatch(expr); m != nil { - return "steps.pick-experiment.outputs." + m[1] + m[2] + // Convert double quotes to single quotes: GitHub Actions expressions only + // support single-quoted string literals, not double-quoted ones. + // This replacement is safe because experimentComparisonRegex guarantees + // that quotes only appear as delimiters around the string literal value; + // no embedded quotes of the same kind are allowed by the pattern. + remainder := strings.ReplaceAll(m[2], `"`, `'`) + return "steps.pick-experiment.outputs." + m[1] + remainder } return expr } diff --git a/pkg/workflow/expression_extraction_test.go b/pkg/workflow/expression_extraction_test.go index aebb2a2ba1f..912fb8cd0d1 100644 --- a/pkg/workflow/expression_extraction_test.go +++ b/pkg/workflow/expression_extraction_test.go @@ -65,25 +65,31 @@ func TestExpressionExtractor_ExtractExpressions(t *testing.T) { name: "experiments.name == value comparison form gets transformed to step output", markdown: `{{#if ${{ experiments.prompt_style == "concise" }} }}foo{{/if}}`, wantCount: 1, - wantExpressions: []string{`steps.pick-experiment.outputs.prompt_style == "concise"`}, + wantExpressions: []string{`steps.pick-experiment.outputs.prompt_style == 'concise'`}, }, { name: "experiments.name === value strict-equality form gets transformed to step output", markdown: `{{#if ${{ experiments.prompt_style === "concise" }} }}foo{{/if}}`, wantCount: 1, - wantExpressions: []string{`steps.pick-experiment.outputs.prompt_style === "concise"`}, + wantExpressions: []string{`steps.pick-experiment.outputs.prompt_style === 'concise'`}, }, { name: "experiments.name != value inequality form gets transformed to step output", markdown: `{{#if ${{ experiments.reasoning_depth != "multi_candidate" }} }}foo{{/if}}`, wantCount: 1, - wantExpressions: []string{`steps.pick-experiment.outputs.reasoning_depth != "multi_candidate"`}, + wantExpressions: []string{`steps.pick-experiment.outputs.reasoning_depth != 'multi_candidate'`}, }, { name: "experiments.name !== value strict-inequality form gets transformed to step output", markdown: `{{#if ${{ experiments.reasoning_depth !== "multi_candidate" }} }}foo{{/if}}`, wantCount: 1, - wantExpressions: []string{`steps.pick-experiment.outputs.reasoning_depth !== "multi_candidate"`}, + wantExpressions: []string{`steps.pick-experiment.outputs.reasoning_depth !== 'multi_candidate'`}, + }, + { + name: "experiments.name == value with single quotes gets transformed to step output", + markdown: `{{#if ${{ experiments.prompt_style == 'concise' }} }}foo{{/if}}`, + wantCount: 1, + wantExpressions: []string{`steps.pick-experiment.outputs.prompt_style == 'concise'`}, }, { name: "expression with whitespace", diff --git a/pkg/workflow/template_include_validation_test.go b/pkg/workflow/template_include_validation_test.go index a1d4231aad2..e7282020420 100644 --- a/pkg/workflow/template_include_validation_test.go +++ b/pkg/workflow/template_include_validation_test.go @@ -532,3 +532,79 @@ func TestValidateNoIncludesInTemplateRegions_SingleError(t *testing.T) { t.Errorf("Error should contain violation: tools.md") } } + +func TestDetectDoubleQuotedExperimentComparisons(t *testing.T) { + tests := []struct { + name string + input string + wantWarnings int + wantContains string + }{ + { + name: "no experiment expressions - no warning", + input: `{{#if github.event.issue.number}}content{{/if}}`, + wantWarnings: 0, + }, + { + name: "experiment with single quotes - no warning", + input: `{{#if experiments.reasoning_depth == 'multi_candidate'}}content{{/if}}`, + wantWarnings: 0, + }, + { + name: "simple experiment name (no comparison) - no warning", + input: `{{#if experiments.feature_flag}}content{{/if}}`, + wantWarnings: 0, + }, + { + name: "experiment with double-quoted value - warning", + input: `{{#if experiments.reasoning_depth == "multi_candidate"}}content{{/if}}`, + wantWarnings: 1, + wantContains: "reasoning_depth", + }, + { + name: "experiment with != and double quotes - warning", + input: `{{#if experiments.mode != "fast"}}content{{/if}}`, + wantWarnings: 1, + wantContains: "experiments.mode", + }, + { + name: "experiment with !== and double quotes - warning", + input: `{{#if experiments.mode !== "fast"}}content{{/if}}`, + wantWarnings: 1, + wantContains: "experiments.mode", + }, + { + name: "elseif with double-quoted value - warning", + input: `{{#if false}}a{{#elseif experiments.style == "detailed"}}b{{/if}}`, + wantWarnings: 1, + wantContains: "experiments.style", + }, + { + name: "multiple occurrences - multiple warnings", + input: "{{#if experiments.a == \"x\"}}a{{/if}}\n{{#if experiments.b == \"y\"}}b{{/if}}", + wantWarnings: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings := detectDoubleQuotedExperimentComparisons(tt.input) + if len(warnings) != tt.wantWarnings { + t.Errorf("detectDoubleQuotedExperimentComparisons() = %d warning(s), want %d; got: %v", + len(warnings), tt.wantWarnings, warnings) + } + if tt.wantContains != "" { + found := false + for _, w := range warnings { + if strings.Contains(w, tt.wantContains) { + found = true + break + } + } + if !found { + t.Errorf("detectDoubleQuotedExperimentComparisons() warnings %v do not contain %q", warnings, tt.wantContains) + } + } + }) + } +} diff --git a/pkg/workflow/template_validation.go b/pkg/workflow/template_validation.go index f0e5903980a..edad80d4ce8 100644 --- a/pkg/workflow/template_validation.go +++ b/pkg/workflow/template_validation.go @@ -51,6 +51,12 @@ var ( // that is produced by the runtime and must never be written manually in workflow markdown. // Authors should use the experiments. form (e.g. experiments.prompt_style == "detailed"). preExpandedExperimentPattern = regexp.MustCompile(`__GH_AW_EXPERIMENTS_[A-Z0-9_]+__`) + + // experimentDoubleQuotePattern matches experiments. comparison expressions that use + // double-quoted string literals (e.g. experiments.mode == "value"). GitHub Actions + // expression syntax only supports single-quoted string literals, so double quotes must be + // replaced with single quotes before the expression reaches the lock file. + experimentDoubleQuotePattern = regexp.MustCompile(`experiments\.[a-zA-Z_][a-zA-Z0-9_]*\s*(?:!==?|===?)\s*"[^"]*"`) ) // validateNoIncludesInTemplateRegions checks that import directives @@ -130,3 +136,39 @@ func validateNoPreExpandedExperimentPlaceholders(markdown string) error { return nil } + +// detectDoubleQuotedExperimentComparisons scans template conditions for experiment comparison +// expressions that use double-quoted string literals (e.g. experiments.mode == "value"). +// GitHub Actions expression syntax only supports single-quoted string literals, so double +// quotes must be replaced with single quotes (e.g. experiments.mode == 'value'). +// +// The compiler converts double quotes to single quotes automatically, but callers should +// surface these findings as warnings so authors are prompted to fix the source. +// +// Returns one message per occurrence found, or nil if none. +func detectDoubleQuotedExperimentComparisons(markdown string) []string { + templateValidationLog.Print("Checking for double-quoted experiment comparison expressions") + + ifConditions := TemplateIfPattern.FindAllStringSubmatch(markdown, -1) + elseifConditions := TemplateElseIfPattern.FindAllStringSubmatch(markdown, -1) + allConditions := append(ifConditions, elseifConditions...) + + var warnings []string + for _, m := range allConditions { + if len(m) < 2 { + continue + } + condition := strings.TrimSpace(m[1]) + if match := experimentDoubleQuotePattern.FindString(condition); match != "" { + warnings = append(warnings, fmt.Sprintf( + "experiment comparison expression uses double quotes: %q — "+ + "GitHub Actions expressions require single quotes; use single quotes instead "+ + "(e.g. experiments.name == 'value')", + match, + )) + } + } + + templateValidationLog.Printf("Found %d double-quoted experiment comparison(s)", len(warnings)) + return warnings +}