From 1df7c53f04343bbc2bed7c3a9150f4cfb7194f30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 12:50:09 +0000 Subject: [PATCH 1/4] chore: initial plan for elseif template support Agent-Logs-Url: https://github.com/github/gh-aw/sessions/40bbec61-dc73-4fb0-aa5a-97a5823cd269 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/stale-pr-cleanup.lock.yml | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/stale-pr-cleanup.lock.yml b/.github/workflows/stale-pr-cleanup.lock.yml index 654e891948..885ba45c79 100644 --- a/.github/workflows/stale-pr-cleanup.lock.yml +++ b/.github/workflows/stale-pr-cleanup.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"07e071eb8a3e78f3eecb8071e562e1f8291f76cb7a7610e5e007f8d4d12f0a43","strict":true,"agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"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"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.38"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.38"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.38"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.38"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.6"},{"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"}]} +# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"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"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.39"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.39"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.39"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.39"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.6"},{"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"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -38,10 +38,10 @@ # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 # # Container images used: -# - ghcr.io/github/gh-aw-firewall/agent:0.25.38 -# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.38 -# - ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.38 -# - ghcr.io/github/gh-aw-firewall/squid:0.25.38 +# - ghcr.io/github/gh-aw-firewall/agent:0.25.39 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.39 +# - ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.39 +# - ghcr.io/github/gh-aw-firewall/squid:0.25.39 # - ghcr.io/github/gh-aw-mcpg:v0.3.6 # - ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959 # - node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f @@ -112,7 +112,7 @@ jobs: GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.25.38" + GH_AW_INFO_AWF_VERSION: "v0.25.39" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" @@ -393,7 +393,7 @@ jobs: env: GH_HOST: github.com - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.38 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.39 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 @@ -416,7 +416,7 @@ jobs: GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.38 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.38 ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.38 ghcr.io/github/gh-aw-firewall/squid:0.25.38 ghcr.io/github/gh-aw-mcpg:v0.3.6 ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959 node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.39 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.39 ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.39 ghcr.io/github/gh-aw-firewall/squid:0.25.39 ghcr.io/github/gh-aw-mcpg:v0.3.6 ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959 node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -734,7 +734,7 @@ jobs: GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN (umask 177 && touch /tmp/gh-aw/agent-stdio.log) - printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.38/awf-config.schema.json","network":{"allowDomains":["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","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","github.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.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"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.38"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.39/awf-config.schema.json","network":{"allowDomains":["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","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","github.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.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"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.39"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull --difc-proxy-host host.docker.internal:18443 --difc-proxy-ca-cert /tmp/gh-aw/difc-proxy-tls/ca.crt \ -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || echo node)"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date *)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq *)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log @@ -1135,7 +1135,7 @@ jobs: rm -rf /tmp/gh-aw/sandbox/firewall/logs rm -rf /tmp/gh-aw/sandbox/firewall/audit - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.38 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.38 ghcr.io/github/gh-aw-firewall/squid:0.25.38 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.39 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.39 ghcr.io/github/gh-aw-firewall/squid:0.25.39 - name: Check if detection needed id: detection_guard if: always() @@ -1198,7 +1198,7 @@ jobs: env: GH_HOST: github.com - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.38 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.39 - name: Execute GitHub Copilot CLI if: always() && steps.detection_guard.outputs.run_detection == 'true' continue-on-error: true @@ -1211,7 +1211,7 @@ jobs: GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) - printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.38/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.38"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.39/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.39"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || echo node)"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log From 2b47905041fc104331c981e8a1457e58730d3392 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:01:15 +0000 Subject: [PATCH 2/4] feat: add elseif handler syntax support in template expression rendering - Add TemplateElseIfPattern to expression_patterns.go supporting all syntax variants: {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} - Update wrapExpressionsInTemplateConditionals to wrap and normalise elseif expressions to canonical {{#elseif}} form - Update validateNoPreExpandedExperimentPlaceholders to check elseif conditions for internal placeholder misuse - Add selectBranch helper to interpolate_prompt.cjs and render_template.cjs implementing multi-branch evaluation (if / elseif* / else?) - Replace two-way else split with unified selectBranch in both JS engines - Export selectBranch from both JS modules - Add Go tests for elseif wrapping, normalisation, and validation - Add JS tests for all elseif variants and branch selection logic Agent-Logs-Url: https://github.com/github/gh-aw/sessions/40bbec61-dc73-4fb0-aa5a-97a5823cd269 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/interpolate_prompt.cjs | 82 ++++++++++++++----- actions/setup/js/interpolate_prompt.test.cjs | 51 +++++++++++- actions/setup/js/render_template.cjs | 59 ++++++++++--- actions/setup/js/render_template.test.cjs | 56 ++++++++++++- pkg/workflow/expression_patterns.go | 7 ++ pkg/workflow/template.go | 50 ++++++----- .../template_expression_wrapping_test.go | 51 ++++++++++++ .../template_include_validation_test.go | 65 +++++++++++++++ pkg/workflow/template_validation.go | 10 ++- 9 files changed, 374 insertions(+), 57 deletions(-) diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index 26f408f37e..22554dfb8f 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -48,6 +48,55 @@ function interpolateVariables(content, variables) { return result; } +/** + * Selects the appropriate branch from a conditional block that may contain + * {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} + * branches in addition to the optional {{#else}} fallback. + * + * Algorithm: + * 1. Split the body on elseif markers (all syntax variants) — capturing groups + * in the split pattern yield alternating [content, condition, content, ...]. + * 2. Pair each content piece with the condition that guards it: + * - First piece is guarded by the {{#if}} condition (ifCondition). + * - Subsequent pieces are guarded by the preceding elseif condition. + * 3. Check the last piece for a {{#else}} tail and, if found, add an + * unconditional else branch. + * 4. Return the content of the first truthy branch, or null if none matched. + * + * @param {string} ifCondition - The condition from the opening {{#if ...}} tag + * @param {string} body - Everything between the opening tag and {{/if}} + * @returns {string|null} - Content of the first truthy branch, or null + */ +function selectBranch(ifCondition, body) { + // Split on all elseif variants. The capturing group ensures that the condition + // text appears between the content pieces in the resulting array. + // Supported: {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} + const parts = body.split(/[ \t]*\{\{#?else[-_]?if\s+([^}]*)\}\}[ \t]*\n?/); + + // parts alternates: [content0, cond1, content1, cond2, content2, ...] + const branches = [{ condition: ifCondition, content: parts[0] }]; + for (let i = 1; i < parts.length; i += 2) { + branches.push({ condition: parts[i].trim(), content: parts[i + 1] || "" }); + } + + // Check whether the last branch's content contains a {{#else}} tail + const lastBranch = branches[branches.length - 1]; + const elseParts = lastBranch.content.split(/[ \t]*\{\{#else\}\}[ \t]*\n?/); + if (elseParts.length > 1) { + lastBranch.content = elseParts[0]; + // condition: null = unconditional else branch + branches.push({ condition: null, content: elseParts.slice(1).join("{{#else}}") }); + } + + // Return content of the first truthy branch + for (const branch of branches) { + if (branch.condition === null || isTruthy(branch.condition)) { + return branch.content; + } + } + return null; +} + /** * Renders a Markdown template by processing {{#if}} conditional blocks. * When a conditional block is removed (falsy condition) and the template tags @@ -87,30 +136,20 @@ function renderMarkdownTemplate(markdown) { let result = _stripped.replace(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*(?:{{#endif}}|{{\/if}})[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body, closeLine, trailNL) => { blockCount++; const condTrimmed = cond.trim(); - const truthyResult = isTruthy(cond); const bodyPreview = body.substring(0, 60).replace(/\n/g, "\\n"); - core.info(`[renderMarkdownTemplate] Block ${blockCount}: condition="${condTrimmed}" -> ${truthyResult ? "KEEP" : "REMOVE"}`); + core.info(`[renderMarkdownTemplate] Block ${blockCount}: condition="${condTrimmed}" -> evaluating branches`); core.info(`[renderMarkdownTemplate] Body preview: "${bodyPreview}${body.length > 60 ? "..." : ""}"`); - // Split on {{#else}} if present to support two-branch conditionals. - // e.g. {{#if experiments.prompt_style == "concise"}} ... {{#else}} ... {{#endif}} - const elseParts = body.split(/[ \t]*\{\{#else\}\}[ \t]*\n?/); - const trueBranch = elseParts[0]; - const falseBranch = elseParts.length > 1 ? elseParts.slice(1).join("{{#else}}") : null; + // Evaluate the full branch chain (if / elseif* / else?) + const selectedContent = selectBranch(cond, body); - if (truthyResult) { - // Keep the true branch (before {{#else}}, or full body if no {{#else}}) + if (selectedContent !== null) { keptBlocks++; - core.info(`[renderMarkdownTemplate] Action: Keeping ${falseBranch !== null ? "true branch" : "body"} with leading newline=${!!leadNL}`); - return leadNL + trueBranch; + core.info(`[renderMarkdownTemplate] Action: Keeping selected branch with leading newline=${!!leadNL}`); + return leadNL + selectedContent; } else { - // Remove the block, or keep the false branch when {{#else}} is present removedBlocks++; - if (falseBranch !== null) { - core.info(`[renderMarkdownTemplate] Action: Keeping false branch ({{#else}} branch)`); - return leadNL + falseBranch; - } core.info(`[renderMarkdownTemplate] Action: Removing entire block`); return ""; } @@ -127,15 +166,16 @@ function renderMarkdownTemplate(markdown) { result = result.replace(/{{#if\s+([^}]*)}}([\s\S]*?)(?:{{#endif}}|{{\/if}})/g, (_, cond, body) => { inlineCount++; const condTrimmed = cond.trim(); - const truthyResult = isTruthy(cond); const bodyPreview = body.substring(0, 40).replace(/\n/g, "\\n"); - core.info(`[renderMarkdownTemplate] Inline ${inlineCount}: condition="${condTrimmed}" -> ${truthyResult ? "KEEP" : "REMOVE"}`); + const selectedContent = selectBranch(cond, body); + + core.info(`[renderMarkdownTemplate] Inline ${inlineCount}: condition="${condTrimmed}" -> ${selectedContent !== null ? "KEEP" : "REMOVE"}`); core.info(`[renderMarkdownTemplate] Body preview: "${bodyPreview}${body.length > 40 ? "..." : ""}"`); - if (truthyResult) { + if (selectedContent !== null) { keptInline++; - return body; + return selectedContent; } else { removedInline++; return ""; @@ -364,4 +404,4 @@ async function main() { } } -module.exports = { main }; +module.exports = { main, renderMarkdownTemplate, interpolateVariables, selectBranch }; diff --git a/actions/setup/js/interpolate_prompt.test.cjs b/actions/setup/js/interpolate_prompt.test.cjs index 13c6c418e2..7142258ac2 100644 --- a/actions/setup/js/interpolate_prompt.test.cjs +++ b/actions/setup/js/interpolate_prompt.test.cjs @@ -10,11 +10,14 @@ const __filename = fileURLToPath(import.meta.url), global.core = core; const interpolatePromptScript = fs.readFileSync(path.join(__dirname, "interpolate_prompt.cjs"), "utf8"), { isTruthy } = require("./is_truthy.cjs"), + selectBranchMatch = interpolatePromptScript.match(/function selectBranch\(ifCondition, body\)\s*{[\s\S]*?return null;\s*\n?}/), interpolateVariablesMatch = interpolatePromptScript.match(/function interpolateVariables\(content, variables\)\s*{[\s\S]*?return result;[\s\S]*?}/), renderMarkdownTemplateMatch = interpolatePromptScript.match(/function renderMarkdownTemplate\(markdown\)\s*{[\s\S]*?return result;[\s\S]*?}/); +if (!selectBranchMatch) throw new Error("Could not extract selectBranch function from interpolate_prompt.cjs"); if (!interpolateVariablesMatch) throw new Error("Could not extract interpolateVariables function from interpolate_prompt.cjs"); if (!renderMarkdownTemplateMatch) throw new Error("Could not extract renderMarkdownTemplate function from interpolate_prompt.cjs"); -const interpolateVariables = eval(`(${interpolateVariablesMatch[0]})`), +const selectBranch = eval(`(${selectBranchMatch[0]})`), + interpolateVariables = eval(`(${interpolateVariablesMatch[0]})`), renderMarkdownTemplate = eval(`(${renderMarkdownTemplateMatch[0]})`); describe("interpolate_prompt", () => { (describe("interpolateVariables", () => { @@ -114,6 +117,52 @@ describe("interpolate_prompt", () => { it("should support {{/if}} as alternate closing tag", () => { const output = renderMarkdownTemplate("{{#if true}}\nKeep\n{{/if}}"); expect(output).toContain("Keep"); + }), + it("should keep first elseif branch when if is false and elseif is true", () => { + const output = renderMarkdownTemplate("{{#if false}}\nBranch A\n{{#elseif true}}\nBranch B\n{{#endif}}"); + expect(output).toContain("Branch B"); + expect(output).not.toContain("Branch A"); + expect(output).not.toContain("{{#elseif}}"); + }), + it("should skip all elseif branches when none match and use else", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#elseif false}}\nB\n{{#else}}\nFallback\n{{#endif}}"); + expect(output).toContain("Fallback"); + expect(output).not.toContain("Branch A"); + expect(output).not.toContain("Branch B"); + }), + it("should keep the if branch and skip elseif when if is true", () => { + const output = renderMarkdownTemplate("{{#if true}}\nFirst\n{{#elseif true}}\nSecond\n{{#endif}}"); + expect(output).toContain("First"); + expect(output).not.toContain("Second"); + }), + it("should support else-if hyphen syntax variant", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#else-if true}}\nB\n{{#endif}}"); + expect(output).toContain("B"); + expect(output).not.toContain("A"); + }), + it("should support else_if underscore syntax variant", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#else_if true}}\nB\n{{#endif}}"); + expect(output).toContain("B"); + expect(output).not.toContain("A"); + }), + it("should support elseif without hash prefix", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{elseif true}}\nB\n{{#endif}}"); + expect(output).toContain("B"); + expect(output).not.toContain("A"); + }), + it("should handle multiple elseif branches selecting the correct one", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#elseif false}}\nB\n{{#elseif true}}\nC\n{{#elseif true}}\nD\n{{#endif}}"); + expect(output).toContain("C"); + expect(output).not.toContain("A"); + expect(output).not.toContain("B"); + expect(output).not.toContain("D"); + }), + it("should support equality condition in elseif", () => { + // 'something' != "concise" and 'something' != "verbose" so else branch is selected + const output = renderMarkdownTemplate('{{#if something == "concise"}}\nConcise\n{{#elseif something == "verbose"}}\nVerbose\n{{#else}}\nDefault\n{{#endif}}'); + expect(output).toContain("Default"); + expect(output).not.toContain("Concise"); + expect(output).not.toContain("Verbose"); })); }), describe("combined interpolation and template rendering", () => { diff --git a/actions/setup/js/render_template.cjs b/actions/setup/js/render_template.cjs index 8f3813ea58..a5c51a420b 100644 --- a/actions/setup/js/render_template.cjs +++ b/actions/setup/js/render_template.cjs @@ -12,6 +12,44 @@ const fs = require("fs"); const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); const { isTruthy } = require("./is_truthy.cjs"); +/** + * Selects the appropriate branch from a conditional block that may contain + * {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} + * branches in addition to the optional {{#else}} fallback. + * + * @param {string} ifCondition - The condition from the opening {{#if ...}} tag + * @param {string} body - Everything between the opening tag and {{/if}} + * @returns {string|null} - Content of the first truthy branch, or null + */ +function selectBranch(ifCondition, body) { + // Split on all elseif variants. The capturing group ensures that the condition + // text appears between the content pieces in the resulting array. + // Supported: {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} + const parts = body.split(/[ \t]*\{\{#?else[-_]?if\s+([^}]*)\}\}[ \t]*\n?/); + + // parts alternates: [content0, cond1, content1, cond2, content2, ...] + const branches = [{ condition: ifCondition, content: parts[0] }]; + for (let i = 1; i < parts.length; i += 2) { + branches.push({ condition: parts[i].trim(), content: parts[i + 1] || "" }); + } + + // Check whether the last branch's content contains a {{#else}} tail + const lastBranch = branches[branches.length - 1]; + const elseParts = lastBranch.content.split(/[ \t]*\{\{#else\}\}[ \t]*\n?/); + if (elseParts.length > 1) { + lastBranch.content = elseParts[0]; + branches.push({ condition: null, content: elseParts.slice(1).join("{{#else}}") }); + } + + // Return content of the first truthy branch + for (const branch of branches) { + if (branch.condition === null || isTruthy(branch.condition)) { + return branch.content; + } + } + return null; +} + /** * Renders a Markdown template by processing {{#if}} conditional blocks. * When a conditional block is removed (falsy condition) and the template tags @@ -50,16 +88,15 @@ function renderMarkdownTemplate(markdown) { // Uses .*? (non-greedy) with \s* to handle expressions with or without trailing spaces let result = _stripped.replace(/(\n?)([ \t]*{{#if\s+(.*?)\s*}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body) => { blockCount++; - const truthyResult = isTruthy(cond); - core.info(`[renderMarkdownTemplate] Block ${blockCount}: condition="${cond.trim()}" -> ${truthyResult ? "KEEP" : "REMOVE"}`); + core.info(`[renderMarkdownTemplate] Block ${blockCount}: condition="${cond.trim()}" -> evaluating branches`); + + const selectedContent = selectBranch(cond, body); - if (truthyResult) { - // Keep body with leading newline if there was one before the opening tag + if (selectedContent !== null) { keptBlocks++; - return leadNL + body; + return leadNL + selectedContent; } else { - // Remove entire block completely - the line containing the template is removed removedBlocks++; return ""; } @@ -75,13 +112,13 @@ function renderMarkdownTemplate(markdown) { // Uses .*? (non-greedy) with \s* to handle expressions with or without trailing spaces result = result.replace(/{{#if\s+(.*?)\s*}}([\s\S]*?){{\/if}}/g, (_, cond, body) => { inlineCount++; - const truthyResult = isTruthy(cond); + const selectedContent = selectBranch(cond, body); - core.info(`[renderMarkdownTemplate] Inline ${inlineCount}: condition="${cond.trim()}" -> ${truthyResult ? "KEEP" : "REMOVE"}`); + core.info(`[renderMarkdownTemplate] Inline ${inlineCount}: condition="${cond.trim()}" -> ${selectedContent !== null ? "KEEP" : "REMOVE"}`); - if (truthyResult) { + if (selectedContent !== null) { keptInline++; - return body; + return selectedContent; } else { removedInline++; return ""; @@ -152,4 +189,4 @@ function main() { } } -module.exports = { renderMarkdownTemplate, main }; +module.exports = { renderMarkdownTemplate, main, selectBranch }; diff --git a/actions/setup/js/render_template.test.cjs b/actions/setup/js/render_template.test.cjs index fbd9ae1fe5..7a869cc435 100644 --- a/actions/setup/js/render_template.test.cjs +++ b/actions/setup/js/render_template.test.cjs @@ -8,9 +8,12 @@ const __filename = fileURLToPath(import.meta.url), global.core = core; const { isTruthy } = require("./is_truthy.cjs"), renderTemplateScript = fs.readFileSync(path.join(__dirname, "render_template.cjs"), "utf8"), + selectBranchMatch = renderTemplateScript.match(/function selectBranch\(ifCondition, body\)\s*{[\s\S]*?return null;\s*\n?}/), renderMarkdownTemplateMatch = renderTemplateScript.match(/function renderMarkdownTemplate\(markdown\)\s*{[\s\S]*?return result;[\s\S]*?}/); +if (!selectBranchMatch) throw new Error("Could not extract selectBranch function from render_template.cjs"); if (!renderMarkdownTemplateMatch) throw new Error("Could not extract renderMarkdownTemplate function from render_template.cjs"); -const renderMarkdownTemplate = eval(`(${renderMarkdownTemplateMatch[0]})`); +const selectBranch = eval(`(${selectBranchMatch[0]})`), + renderMarkdownTemplate = eval(`(${renderMarkdownTemplateMatch[0]})`); describe("renderMarkdownTemplate", () => { (it("should keep content in truthy blocks", () => { const output = renderMarkdownTemplate("{{#if true}}\nHello\n{{/if}}"); @@ -118,4 +121,55 @@ describe("renderMarkdownTemplate", () => { expect(output).toBe("Keep\n```python\nprint('hello')\n```"); }); }); + describe("elseif support", () => { + it("should keep elseif branch when if is false and elseif is true", () => { + const output = renderMarkdownTemplate("{{#if false}}\nBranch A\n{{#elseif true}}\nBranch B\n{{/if}}"); + expect(output).toContain("Branch B"); + expect(output).not.toContain("Branch A"); + }); + it("should keep if branch and skip elseif when if is true", () => { + const output = renderMarkdownTemplate("{{#if true}}\nFirst\n{{#elseif true}}\nSecond\n{{/if}}"); + expect(output).toContain("First"); + expect(output).not.toContain("Second"); + }); + it("should fall through to else when no if/elseif matches", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#elseif false}}\nB\n{{#else}}\nFallback\n{{/if}}"); + expect(output).toContain("Fallback"); + expect(output).not.toContain("Branch A"); + expect(output).not.toContain("Branch B"); + }); + it("should support else-if hyphen variant", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#else-if true}}\nB\n{{/if}}"); + expect(output).toContain("B"); + expect(output).not.toContain("A"); + }); + it("should support else_if underscore variant", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#else_if true}}\nB\n{{/if}}"); + expect(output).toContain("B"); + expect(output).not.toContain("A"); + }); + it("should support elseif without hash prefix", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{elseif true}}\nB\n{{/if}}"); + expect(output).toContain("B"); + expect(output).not.toContain("A"); + }); + it("should remove entire block when no branch matches", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#elseif false}}\nB\n{{/if}}"); + expect(output).toBe(""); + }); + it("should select first matching branch among multiple elseif", () => { + const output = renderMarkdownTemplate("{{#if false}}\nA\n{{#elseif false}}\nB\n{{#elseif true}}\nC\n{{#elseif true}}\nD\n{{/if}}"); + expect(output).toContain("C"); + expect(output).not.toContain("A"); + expect(output).not.toContain("B"); + expect(output).not.toContain("D"); + }); + it("should support equality condition in elseif", () => { + // 'something' != "concise" and 'something' != "verbose" so else branch is selected + const output = renderMarkdownTemplate('{{#if something == "concise"}}\nConcise\n{{#elseif something == "verbose"}}\nVerbose\n{{#else}}\nDefault\n{{/if}}'); + expect(output).toContain("Default"); + expect(output).not.toContain("Concise"); + expect(output).not.toContain("Verbose"); + }); + }); }); diff --git a/pkg/workflow/expression_patterns.go b/pkg/workflow/expression_patterns.go index 7df7480c41..bc8f3d1833 100644 --- a/pkg/workflow/expression_patterns.go +++ b/pkg/workflow/expression_patterns.go @@ -165,6 +165,13 @@ var ( // TemplateIfPattern matches {{#if condition }} template conditionals // Captures the condition expression (which may contain ${{ ... }}) TemplateIfPattern = regexp.MustCompile(`\{\{#if\s+((?:\$\{\{[^\}]*\}\}|[^\}])*)\s*\}\}`) + + // TemplateElseIfPattern matches elseif/else-if/else_if template conditionals in all supported + // syntax variants: + // {{#elseif expr}} {{#else-if expr}} {{#else_if expr}} + // {{elseif expr}} {{else-if expr}} {{else_if expr}} + // Captures the condition expression (which may contain ${{ ... }}) + TemplateElseIfPattern = regexp.MustCompile(`\{\{#?else[-_]?if\s+((?:\$\{\{[^\}]*\}\}|[^\}])*)\s*\}\}`) ) // Comparison and Literal Patterns diff --git a/pkg/workflow/template.go b/pkg/workflow/template.go index e846e62d1b..e72bb3a8df 100644 --- a/pkg/workflow/template.go +++ b/pkg/workflow/template.go @@ -2,6 +2,7 @@ package workflow import ( "fmt" + "regexp" "strings" "github.com/github/gh-aw/pkg/logger" @@ -12,15 +13,14 @@ var templateLog = logger.New("workflow:template") // wrapExpressionsInTemplateConditionals transforms template conditionals by wrapping // expressions in ${{ }}. For example: // {{#if github.event.issue.number}} becomes {{#if ${{ github.event.issue.number }} }} +// {{#elseif github.actor}} becomes {{#elseif ${{ github.actor }} }} func wrapExpressionsInTemplateConditionals(markdown string) string { - // Reuse the centralized TemplateIfPattern from expression_patterns.go - // Pattern matches {{#if expression}} where expression may contain ${{ }} blocks - re := TemplateIfPattern - templateLog.Print("Wrapping expressions in template conditionals") - result := re.ReplaceAllStringFunc(markdown, func(match string) string { - // Extract the expression part (everything between "{{#if " and "}}") + // wrapTagExpr applies the wrapping logic to a single extracted expression and + // returns the full reconstructed tag. prefix is the opening tag text without the + // expression (e.g. "{{#if " or "{{#elseif "). + wrapTagExpr := func(re *regexp.Regexp, match, prefix string) string { submatches := re.FindStringSubmatch(match) if len(submatches) < 2 { return match @@ -28,37 +28,49 @@ func wrapExpressionsInTemplateConditionals(markdown string) string { expr := strings.TrimSpace(submatches[1]) - // Check if expression is empty (after trimming) // Empty expressions are treated as false and wrapped as such if expr == "" { templateLog.Print("Empty expression detected, wrapping as false") - return "{{#if ${{ false }} }}" + return prefix + "${{ false }} }}" } - // Check if expression is already wrapped in ${{ ... }} - // Look for the pattern starting with "${{" + // Already wrapped in ${{ ... }} — return as-is if strings.HasPrefix(expr, "${{") { templateLog.Print("Expression already wrapped, skipping") - return match // Already wrapped, return as-is + return match } - // Check if expression is an environment variable reference (starts with ${) - // These don't need ${{ }} wrapping as they're already evaluated + // Environment variable reference (starts with ${) — already evaluated if strings.HasPrefix(expr, "${") { templateLog.Print("Environment variable reference detected, skipping wrap") - return match // Environment variable reference, return as-is + return match } - // Check if expression is a placeholder reference (starts with __) - // These are substituted with sed and don't need ${{ }} wrapping + // Placeholder reference (starts with __) — substituted at runtime if strings.HasPrefix(expr, "__") { templateLog.Print("Placeholder reference detected, skipping wrap") - return match // Placeholder reference, return as-is + return match } - // Always wrap expressions that don't start with ${{ or ${ or __ templateLog.Printf("Wrapping expression: %s", expr) - return "{{#if ${{ " + expr + " }} }}" + return prefix + "${{ " + expr + " }} }}" + } + + // Process {{#if ...}} tags + result := TemplateIfPattern.ReplaceAllStringFunc(markdown, func(match string) string { + return wrapTagExpr(TemplateIfPattern, match, "{{#if ") + }) + + // Process all elseif variant tags — normalise to canonical {{#elseif ...}} form after wrapping + result = TemplateElseIfPattern.ReplaceAllStringFunc(result, func(match string) string { + submatches := TemplateElseIfPattern.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + wrapped := wrapTagExpr(TemplateElseIfPattern, match, "{{#elseif ") + // If the original tag used a non-canonical form (else-if / else_if / without #), + // the prefix replacement above already normalises it to {{#elseif ...}}. + return wrapped }) return result diff --git a/pkg/workflow/template_expression_wrapping_test.go b/pkg/workflow/template_expression_wrapping_test.go index 9f9ea753dd..363af60216 100644 --- a/pkg/workflow/template_expression_wrapping_test.go +++ b/pkg/workflow/template_expression_wrapping_test.go @@ -241,6 +241,57 @@ Regular content`, input: "{{#if github.actor}}A{{/if}} {{#if }}B{{/if}} {{#if true}}C{{/if}}", expected: "{{#if ${{ github.actor }} }}A{{/if}} {{#if ${{ false }} }}B{{/if}} {{#if ${{ true }} }}C{{/if}}", }, + // elseif variants — expression wrapping and canonical normalisation + { + name: "canonical elseif wraps expression", + input: "{{#if github.actor}}A{{#elseif github.repository}}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${{ github.repository }} }}B{{/if}}", + }, + { + name: "else-if hyphen variant normalised to canonical", + input: "{{#if github.actor}}A{{#else-if github.repository}}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${{ github.repository }} }}B{{/if}}", + }, + { + name: "else_if underscore variant normalised to canonical", + input: "{{#if github.actor}}A{{#else_if github.repository}}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${{ github.repository }} }}B{{/if}}", + }, + { + name: "elseif without hash prefix normalised to canonical", + input: "{{#if github.actor}}A{{elseif github.repository}}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${{ github.repository }} }}B{{/if}}", + }, + { + name: "else-if without hash prefix normalised to canonical", + input: "{{#if github.actor}}A{{else-if github.repository}}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${{ github.repository }} }}B{{/if}}", + }, + { + name: "else_if without hash prefix normalised to canonical", + input: "{{#if github.actor}}A{{else_if github.repository}}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${{ github.repository }} }}B{{/if}}", + }, + { + name: "elseif already wrapped expression skipped", + input: "{{#if github.actor}}A{{#elseif ${{ github.repository }} }}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${{ github.repository }} }}B{{/if}}", + }, + { + name: "multiple elseif branches wrapped", + input: "{{#if github.actor}}A{{#elseif github.repository}}B{{#elseif env.MY_VAR}}C{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${{ github.repository }} }}B{{#elseif ${{ env.MY_VAR }} }}C{{/if}}", + }, + { + name: "elseif with env var reference skipped", + input: "{{#if github.actor}}A{{#elseif ${GH_AW_EXPR_REPO}}}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif ${GH_AW_EXPR_REPO}}}B{{/if}}", + }, + { + name: "elseif with placeholder reference skipped", + input: "{{#if github.actor}}A{{#elseif __GH_AW_VAR__}}B{{/if}}", + expected: "{{#if ${{ github.actor }} }}A{{#elseif __GH_AW_VAR__}}B{{/if}}", + }, } for _, tt := range tests { diff --git a/pkg/workflow/template_include_validation_test.go b/pkg/workflow/template_include_validation_test.go index 2836a719bf..a1d4231aad 100644 --- a/pkg/workflow/template_include_validation_test.go +++ b/pkg/workflow/template_include_validation_test.go @@ -446,6 +446,71 @@ Even more content. } } +// TestValidateNoPreExpandedExperimentPlaceholders_ElseIf tests that elseif conditions +// are also checked for pre-expanded experiment placeholders. +func TestValidateNoPreExpandedExperimentPlaceholders_ElseIf(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + errMsg string + }{ + { + name: "valid - experiments.name in if condition", + input: `{{#if experiments.prompt_style == "detailed"}}content{{/if}}`, + wantErr: false, + }, + { + name: "valid - experiments.name in elseif condition", + input: `{{#if false}}a{{#elseif experiments.prompt_style == "detailed"}}content{{/if}}`, + wantErr: false, + }, + { + name: "invalid - pre-expanded placeholder in if condition", + input: `{{#if __GH_AW_EXPERIMENTS_PROMPT_STYLE__ == "detailed"}}content{{/if}}`, + wantErr: true, + errMsg: "pre-expanded experiment placeholder", + }, + { + name: "invalid - pre-expanded placeholder in elseif condition", + input: `{{#if false}}a{{#elseif __GH_AW_EXPERIMENTS_PROMPT_STYLE__ == "detailed"}}content{{/if}}`, + wantErr: true, + errMsg: "pre-expanded experiment placeholder", + }, + { + name: "invalid - pre-expanded placeholder in else-if (hyphen) condition", + input: `{{#if false}}a{{#else-if __GH_AW_EXPERIMENTS_FEATURE__ == "on"}}content{{/if}}`, + wantErr: true, + errMsg: "pre-expanded experiment placeholder", + }, + { + name: "invalid - pre-expanded placeholder in else_if (underscore) condition", + input: `{{#if false}}a{{#else_if __GH_AW_EXPERIMENTS_FEATURE__}}content{{/if}}`, + wantErr: true, + errMsg: "pre-expanded experiment placeholder", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateNoPreExpandedExperimentPlaceholders(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("validateNoPreExpandedExperimentPlaceholders() expected error, got nil") + return + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("validateNoPreExpandedExperimentPlaceholders() error = %q, want to contain %q", err.Error(), tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validateNoPreExpandedExperimentPlaceholders() unexpected error = %v", err) + } + } + }) + } +} + // TestValidateNoIncludesInTemplateRegions_SingleError tests single error behavior func TestValidateNoIncludesInTemplateRegions_SingleError(t *testing.T) { // Input with single include inside template region diff --git a/pkg/workflow/template_validation.go b/pkg/workflow/template_validation.go index bcd0b7567a..f0e5903980 100644 --- a/pkg/workflow/template_validation.go +++ b/pkg/workflow/template_validation.go @@ -103,12 +103,14 @@ func validateNoIncludesInTemplateRegions(markdown string) error { func validateNoPreExpandedExperimentPlaceholders(markdown string) error { templateValidationLog.Print("Validating that pre-expanded experiment placeholders are not used in template conditions") - // Use TemplateIfPattern which correctly handles embedded ${{ ... }} blocks inside conditions - conditions := TemplateIfPattern.FindAllStringSubmatch(markdown, -1) - templateValidationLog.Printf("Found %d template condition(s) to validate", len(conditions)) + // Collect conditions from both {{#if ...}} and all elseif variants + ifConditions := TemplateIfPattern.FindAllStringSubmatch(markdown, -1) + elseifConditions := TemplateElseIfPattern.FindAllStringSubmatch(markdown, -1) + allConditions := append(ifConditions, elseifConditions...) + templateValidationLog.Printf("Found %d template condition(s) to validate", len(allConditions)) var errs []error - for _, m := range conditions { + for _, m := range allConditions { if len(m) < 2 { continue } From 8beb17c63a452c1ecbee57ee4657287c16c5526e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 13:09:20 +0000 Subject: [PATCH 3/4] refactor: extract selectBranch to shared template_branch.cjs utility Address code review feedback: remove duplication by moving selectBranch from interpolate_prompt.cjs and render_template.cjs into a single shared template_branch.cjs module. Both consumers now require the shared module. Also clarify the wrapTagExpr closure comment in template.go. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/40bbec61-dc73-4fb0-aa5a-97a5823cd269 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/interpolate_prompt.cjs | 52 +----------------- actions/setup/js/interpolate_prompt.test.cjs | 10 ++-- actions/setup/js/render_template.cjs | 41 +------------- actions/setup/js/render_template.test.cjs | 6 +-- actions/setup/js/template_branch.cjs | 57 ++++++++++++++++++++ pkg/workflow/template.go | 6 ++- 6 files changed, 71 insertions(+), 101 deletions(-) create mode 100644 actions/setup/js/template_branch.cjs diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index 22554dfb8f..2d7da5adbe 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -10,6 +10,7 @@ const fs = require("fs"); const { isTruthy } = require("./is_truthy.cjs"); +const { selectBranch } = require("./template_branch.cjs"); const { processRuntimeImports } = require("./runtime_import.cjs"); const { writeInlineSubAgents } = require("./extract_inline_sub_agents.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); @@ -48,55 +49,6 @@ function interpolateVariables(content, variables) { return result; } -/** - * Selects the appropriate branch from a conditional block that may contain - * {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} - * branches in addition to the optional {{#else}} fallback. - * - * Algorithm: - * 1. Split the body on elseif markers (all syntax variants) — capturing groups - * in the split pattern yield alternating [content, condition, content, ...]. - * 2. Pair each content piece with the condition that guards it: - * - First piece is guarded by the {{#if}} condition (ifCondition). - * - Subsequent pieces are guarded by the preceding elseif condition. - * 3. Check the last piece for a {{#else}} tail and, if found, add an - * unconditional else branch. - * 4. Return the content of the first truthy branch, or null if none matched. - * - * @param {string} ifCondition - The condition from the opening {{#if ...}} tag - * @param {string} body - Everything between the opening tag and {{/if}} - * @returns {string|null} - Content of the first truthy branch, or null - */ -function selectBranch(ifCondition, body) { - // Split on all elseif variants. The capturing group ensures that the condition - // text appears between the content pieces in the resulting array. - // Supported: {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} - const parts = body.split(/[ \t]*\{\{#?else[-_]?if\s+([^}]*)\}\}[ \t]*\n?/); - - // parts alternates: [content0, cond1, content1, cond2, content2, ...] - const branches = [{ condition: ifCondition, content: parts[0] }]; - for (let i = 1; i < parts.length; i += 2) { - branches.push({ condition: parts[i].trim(), content: parts[i + 1] || "" }); - } - - // Check whether the last branch's content contains a {{#else}} tail - const lastBranch = branches[branches.length - 1]; - const elseParts = lastBranch.content.split(/[ \t]*\{\{#else\}\}[ \t]*\n?/); - if (elseParts.length > 1) { - lastBranch.content = elseParts[0]; - // condition: null = unconditional else branch - branches.push({ condition: null, content: elseParts.slice(1).join("{{#else}}") }); - } - - // Return content of the first truthy branch - for (const branch of branches) { - if (branch.condition === null || isTruthy(branch.condition)) { - return branch.content; - } - } - return null; -} - /** * Renders a Markdown template by processing {{#if}} conditional blocks. * When a conditional block is removed (falsy condition) and the template tags @@ -404,4 +356,4 @@ async function main() { } } -module.exports = { main, renderMarkdownTemplate, interpolateVariables, selectBranch }; +module.exports = { main, renderMarkdownTemplate, interpolateVariables }; diff --git a/actions/setup/js/interpolate_prompt.test.cjs b/actions/setup/js/interpolate_prompt.test.cjs index 7142258ac2..e2a2ebdd96 100644 --- a/actions/setup/js/interpolate_prompt.test.cjs +++ b/actions/setup/js/interpolate_prompt.test.cjs @@ -8,16 +8,14 @@ const __filename = fileURLToPath(import.meta.url), __dirname = path.dirname(__filename), core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() }; global.core = core; -const interpolatePromptScript = fs.readFileSync(path.join(__dirname, "interpolate_prompt.cjs"), "utf8"), - { isTruthy } = require("./is_truthy.cjs"), - selectBranchMatch = interpolatePromptScript.match(/function selectBranch\(ifCondition, body\)\s*{[\s\S]*?return null;\s*\n?}/), +const { isTruthy } = require("./is_truthy.cjs"), + { selectBranch } = require("./template_branch.cjs"), + interpolatePromptScript = fs.readFileSync(path.join(__dirname, "interpolate_prompt.cjs"), "utf8"), interpolateVariablesMatch = interpolatePromptScript.match(/function interpolateVariables\(content, variables\)\s*{[\s\S]*?return result;[\s\S]*?}/), renderMarkdownTemplateMatch = interpolatePromptScript.match(/function renderMarkdownTemplate\(markdown\)\s*{[\s\S]*?return result;[\s\S]*?}/); -if (!selectBranchMatch) throw new Error("Could not extract selectBranch function from interpolate_prompt.cjs"); if (!interpolateVariablesMatch) throw new Error("Could not extract interpolateVariables function from interpolate_prompt.cjs"); if (!renderMarkdownTemplateMatch) throw new Error("Could not extract renderMarkdownTemplate function from interpolate_prompt.cjs"); -const selectBranch = eval(`(${selectBranchMatch[0]})`), - interpolateVariables = eval(`(${interpolateVariablesMatch[0]})`), +const interpolateVariables = eval(`(${interpolateVariablesMatch[0]})`), renderMarkdownTemplate = eval(`(${renderMarkdownTemplateMatch[0]})`); describe("interpolate_prompt", () => { (describe("interpolateVariables", () => { diff --git a/actions/setup/js/render_template.cjs b/actions/setup/js/render_template.cjs index a5c51a420b..e87d12d87a 100644 --- a/actions/setup/js/render_template.cjs +++ b/actions/setup/js/render_template.cjs @@ -11,44 +11,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const fs = require("fs"); const { ERR_API, ERR_CONFIG } = require("./error_codes.cjs"); const { isTruthy } = require("./is_truthy.cjs"); - -/** - * Selects the appropriate branch from a conditional block that may contain - * {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} - * branches in addition to the optional {{#else}} fallback. - * - * @param {string} ifCondition - The condition from the opening {{#if ...}} tag - * @param {string} body - Everything between the opening tag and {{/if}} - * @returns {string|null} - Content of the first truthy branch, or null - */ -function selectBranch(ifCondition, body) { - // Split on all elseif variants. The capturing group ensures that the condition - // text appears between the content pieces in the resulting array. - // Supported: {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} - const parts = body.split(/[ \t]*\{\{#?else[-_]?if\s+([^}]*)\}\}[ \t]*\n?/); - - // parts alternates: [content0, cond1, content1, cond2, content2, ...] - const branches = [{ condition: ifCondition, content: parts[0] }]; - for (let i = 1; i < parts.length; i += 2) { - branches.push({ condition: parts[i].trim(), content: parts[i + 1] || "" }); - } - - // Check whether the last branch's content contains a {{#else}} tail - const lastBranch = branches[branches.length - 1]; - const elseParts = lastBranch.content.split(/[ \t]*\{\{#else\}\}[ \t]*\n?/); - if (elseParts.length > 1) { - lastBranch.content = elseParts[0]; - branches.push({ condition: null, content: elseParts.slice(1).join("{{#else}}") }); - } - - // Return content of the first truthy branch - for (const branch of branches) { - if (branch.condition === null || isTruthy(branch.condition)) { - return branch.content; - } - } - return null; -} +const { selectBranch } = require("./template_branch.cjs"); /** * Renders a Markdown template by processing {{#if}} conditional blocks. @@ -189,4 +152,4 @@ function main() { } } -module.exports = { renderMarkdownTemplate, main, selectBranch }; +module.exports = { renderMarkdownTemplate, main }; diff --git a/actions/setup/js/render_template.test.cjs b/actions/setup/js/render_template.test.cjs index 7a869cc435..c0d29bbf7e 100644 --- a/actions/setup/js/render_template.test.cjs +++ b/actions/setup/js/render_template.test.cjs @@ -7,13 +7,11 @@ const __filename = fileURLToPath(import.meta.url), core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn(), summary: { addHeading: vi.fn().mockReturnThis(), addRaw: vi.fn().mockReturnThis(), write: vi.fn() } }; global.core = core; const { isTruthy } = require("./is_truthy.cjs"), + { selectBranch } = require("./template_branch.cjs"), renderTemplateScript = fs.readFileSync(path.join(__dirname, "render_template.cjs"), "utf8"), - selectBranchMatch = renderTemplateScript.match(/function selectBranch\(ifCondition, body\)\s*{[\s\S]*?return null;\s*\n?}/), renderMarkdownTemplateMatch = renderTemplateScript.match(/function renderMarkdownTemplate\(markdown\)\s*{[\s\S]*?return result;[\s\S]*?}/); -if (!selectBranchMatch) throw new Error("Could not extract selectBranch function from render_template.cjs"); if (!renderMarkdownTemplateMatch) throw new Error("Could not extract renderMarkdownTemplate function from render_template.cjs"); -const selectBranch = eval(`(${selectBranchMatch[0]})`), - renderMarkdownTemplate = eval(`(${renderMarkdownTemplateMatch[0]})`); +const renderMarkdownTemplate = eval(`(${renderMarkdownTemplateMatch[0]})`); describe("renderMarkdownTemplate", () => { (it("should keep content in truthy blocks", () => { const output = renderMarkdownTemplate("{{#if true}}\nHello\n{{/if}}"); diff --git a/actions/setup/js/template_branch.cjs b/actions/setup/js/template_branch.cjs new file mode 100644 index 0000000000..575d167ee9 --- /dev/null +++ b/actions/setup/js/template_branch.cjs @@ -0,0 +1,57 @@ +// @ts-check + +// template_branch.cjs +// Shared branch-selection logic for {{#if / #elseif* / #else}} template conditionals. + +const { isTruthy } = require("./is_truthy.cjs"); + +/** + * Selects the appropriate branch from a conditional block that may contain + * {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} + * branches in addition to the optional {{#else}} fallback. + * + * Algorithm: + * 1. Split the body on elseif markers (all syntax variants) — capturing groups + * in the split pattern yield alternating [content, condition, content, ...]. + * 2. Pair each content piece with the condition that guards it: + * - First piece is guarded by the {{#if}} condition (ifCondition). + * - Subsequent pieces are guarded by the preceding elseif condition. + * 3. Check the last piece for a {{#else}} tail and, if found, add an + * unconditional else branch. + * 4. Return the content of the first truthy branch, or null if none matched. + * + * @param {string} ifCondition - The condition from the opening {{#if ...}} tag + * @param {string} body - Everything between the opening tag and {{/if}} + * @returns {string|null} - Content of the first truthy branch, or null + */ +function selectBranch(ifCondition, body) { + // Split on all elseif variants. The capturing group ensures that the condition + // text appears between the content pieces in the resulting array. + // Supported: {{#elseif}}, {{#else-if}}, {{#else_if}}, {{elseif}}, {{else-if}}, {{else_if}} + const parts = body.split(/[ \t]*\{\{#?else[-_]?if\s+([^}]*)\}\}[ \t]*\n?/); + + // parts alternates: [content0, cond1, content1, cond2, content2, ...] + const branches = [{ condition: ifCondition, content: parts[0] }]; + for (let i = 1; i < parts.length; i += 2) { + branches.push({ condition: parts[i].trim(), content: parts[i + 1] || "" }); + } + + // Check whether the last branch's content contains a {{#else}} tail + const lastBranch = branches[branches.length - 1]; + const elseParts = lastBranch.content.split(/[ \t]*\{\{#else\}\}[ \t]*\n?/); + if (elseParts.length > 1) { + lastBranch.content = elseParts[0]; + // condition: null = unconditional else branch + branches.push({ condition: null, content: elseParts.slice(1).join("{{#else}}") }); + } + + // Return content of the first truthy branch + for (const branch of branches) { + if (branch.condition === null || isTruthy(branch.condition)) { + return branch.content; + } + } + return null; +} + +module.exports = { selectBranch }; diff --git a/pkg/workflow/template.go b/pkg/workflow/template.go index e72bb3a8df..6d2bddb33b 100644 --- a/pkg/workflow/template.go +++ b/pkg/workflow/template.go @@ -18,8 +18,10 @@ func wrapExpressionsInTemplateConditionals(markdown string) string { templateLog.Print("Wrapping expressions in template conditionals") // wrapTagExpr applies the wrapping logic to a single extracted expression and - // returns the full reconstructed tag. prefix is the opening tag text without the - // expression (e.g. "{{#if " or "{{#elseif "). + // returns the full reconstructed tag. re must be the same regex that produced + // match (so FindStringSubmatch reliably extracts capture group 1 = the expression). + // prefix is the canonical opening tag text without the expression + // (e.g. "{{#if " or "{{#elseif "), used to rebuild the output tag. wrapTagExpr := func(re *regexp.Regexp, match, prefix string) string { submatches := re.FindStringSubmatch(match) if len(submatches) < 2 { From 424d11eb468d4a3a76e9acb00ed69e0d17615d54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 14:06:55 +0000 Subject: [PATCH 4/4] test: add fuzz tests for elseif template expression rendering - Extend FuzzWrapExpressionsInTemplateConditionals seed corpus with all 6 elseif syntax variants and add invariant check that non-canonical elseif forms are fully normalised to {{#elseif}} in the output - Add fuzz_template_branch_harness.cjs JS harness for selectBranch and renderMarkdownTemplate with elseif support - Add fuzz_template_branch_harness.test.cjs with 18 seed-corpus test cases covering all elseif syntax variants, multi-branch chains, equality conditions, and crash-safety invariants Agent-Logs-Url: https://github.com/github/gh-aw/sessions/fd7aa0eb-74aa-454e-bd7a-7d7eee453d71 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/fuzz_template_branch_harness.cjs | 61 ++++++++++ .../js/fuzz_template_branch_harness.test.cjs | 104 ++++++++++++++++++ pkg/workflow/template_fuzz_test.go | 35 ++++++ 3 files changed, 200 insertions(+) create mode 100644 actions/setup/js/fuzz_template_branch_harness.cjs create mode 100644 actions/setup/js/fuzz_template_branch_harness.test.cjs diff --git a/actions/setup/js/fuzz_template_branch_harness.cjs b/actions/setup/js/fuzz_template_branch_harness.cjs new file mode 100644 index 0000000000..6712ab8b9c --- /dev/null +++ b/actions/setup/js/fuzz_template_branch_harness.cjs @@ -0,0 +1,61 @@ +// @ts-check +/** + * Fuzz harness for {{#if / #elseif / #else}} template branch selection and rendering. + * + * Reads a JSON test case from stdin: + * { ifCondition: string, body: string } — for selectBranch + * { markdown: string } — for renderMarkdownTemplate + * + * Writes the result as JSON to stdout: + * { result: string|null, error: string|null } + * + * Used by the Go fuzz driver in template_conditional_js_fuzz_test.go. + */ + +const { selectBranch } = require("./template_branch.cjs"); +const { isTruthy } = require("./is_truthy.cjs"); + +// Minimal shim so renderMarkdownTemplate can call core.info +if (!global.core) { + global.core = { + info: () => {}, + warning: () => {}, + setFailed: () => {}, + }; +} + +const renderTemplateScript = require("fs").readFileSync(require("path").join(__dirname, "render_template.cjs"), "utf8"); +const renderMarkdownTemplateMatch = renderTemplateScript.match(/function renderMarkdownTemplate\(markdown\)\s*{[\s\S]*?return result;[\s\S]*?}/); +if (!renderMarkdownTemplateMatch) throw new Error("Could not extract renderMarkdownTemplate"); +const renderMarkdownTemplate = eval(`(${renderMarkdownTemplateMatch[0]})`); + +if (require.main === module) { + let input = ""; + process.stdin.on("data", chunk => { + input += chunk; + }); + process.stdin.on("end", () => { + try { + const parsed = JSON.parse(input); + + let result; + if (Object.prototype.hasOwnProperty.call(parsed, "ifCondition")) { + // selectBranch test + result = { result: selectBranch(parsed.ifCondition, parsed.body || ""), error: null }; + } else if (Object.prototype.hasOwnProperty.call(parsed, "markdown")) { + // renderMarkdownTemplate test + result = { result: renderMarkdownTemplate(parsed.markdown || ""), error: null }; + } else { + result = { result: null, error: "Unknown test type: expected 'ifCondition' or 'markdown' key" }; + } + + process.stdout.write(JSON.stringify(result)); + process.exit(0); + } catch (err) { + process.stdout.write(JSON.stringify({ result: null, error: err instanceof Error ? err.message : String(err) })); + process.exit(1); + } + }); +} + +module.exports = { selectBranch, renderMarkdownTemplate }; diff --git a/actions/setup/js/fuzz_template_branch_harness.test.cjs b/actions/setup/js/fuzz_template_branch_harness.test.cjs new file mode 100644 index 0000000000..1ce7b63d4e --- /dev/null +++ b/actions/setup/js/fuzz_template_branch_harness.test.cjs @@ -0,0 +1,104 @@ +// @ts-check +import { describe, it, expect, vi } from "vitest"; +global.core = { info: vi.fn(), warning: vi.fn(), setFailed: vi.fn() }; + +const { selectBranch, renderMarkdownTemplate } = require("./fuzz_template_branch_harness.cjs"); + +describe("fuzz_template_branch_harness", () => { + describe("selectBranch — seed corpus", () => { + it("returns if-branch when condition is truthy (no elseif)", () => { + expect(selectBranch("true", "content\n")).toBe("content\n"); + }); + + it("returns null when condition is falsy (no elseif, no else)", () => { + expect(selectBranch("false", "content\n")).toBeNull(); + }); + + it("returns elseif branch when if is false and elseif is true", () => { + const body = "Branch A\n{{#elseif true}}\nBranch B\n"; + expect(selectBranch("false", body)).toContain("Branch B"); + }); + + it("returns else branch when all conditions are false", () => { + const body = "A\n{{#elseif false}}\nB\n{{#else}}\nFallback\n"; + expect(selectBranch("false", body)).toContain("Fallback"); + }); + + it("returns first matching elseif among many", () => { + const body = "A\n{{#elseif false}}\nB\n{{#elseif true}}\nC\n{{#elseif true}}\nD\n"; + const result = selectBranch("false", body); + expect(result).toContain("C"); + expect(result).not.toContain("D"); + }); + + it("handles else-if hyphen variant", () => { + const body = "A\n{{#else-if true}}\nB\n"; + expect(selectBranch("false", body)).toContain("B"); + }); + + it("handles else_if underscore variant", () => { + const body = "A\n{{#else_if true}}\nB\n"; + expect(selectBranch("false", body)).toContain("B"); + }); + + it("handles elseif without hash", () => { + const body = "A\n{{elseif true}}\nB\n"; + expect(selectBranch("false", body)).toContain("B"); + }); + + it("returns null for fully-false chain with no else", () => { + const body = "A\n{{#elseif false}}\nB\n{{#elseif false}}\nC\n"; + expect(selectBranch("false", body)).toBeNull(); + }); + + it("handles equality condition in elseif (experiment-style)", () => { + // concise == "concise" is truthy + const body = 'A\n{{#elseif concise == "concise"}}\nB\n{{#else}}\nC\n'; + const result = selectBranch("false", body); + expect(result).toContain("B"); + expect(result).not.toContain("C"); + }); + + it("does not crash on empty body", () => { + expect(() => selectBranch("true", "")).not.toThrow(); + }); + + it("does not crash on empty condition", () => { + expect(() => selectBranch("", "content")).not.toThrow(); + }); + + it("does not crash on deeply nested elseif chain", () => { + let body = ""; + for (let i = 0; i < 50; i++) { + body += `Branch ${i}\n{{#elseif false}}\n`; + } + body += `Last\n`; + expect(() => selectBranch("false", body)).not.toThrow(); + }); + }); + + describe("renderMarkdownTemplate — elseif integration", () => { + it("renders elseif branch when if is false", () => { + const md = "{{#if false}}\nA\n{{#elseif true}}\nB\n{{/if}}"; + expect(renderMarkdownTemplate(md)).toContain("B"); + }); + + it("renders else fallback when all elseif branches are false", () => { + const md = "{{#if false}}\nA\n{{#elseif false}}\nB\n{{#else}}\nC\n{{/if}}"; + expect(renderMarkdownTemplate(md)).toContain("C"); + }); + + it("does not crash on arbitrary whitespace in elseif condition", () => { + const md = "{{#if false}}\nA\n{{#elseif true }}\nB\n{{/if}}"; + expect(() => renderMarkdownTemplate(md)).not.toThrow(); + }); + + it("does not crash on empty string", () => { + expect(() => renderMarkdownTemplate("")).not.toThrow(); + }); + + it("does not crash on malformed elseif (no closing }}", () => { + expect(() => renderMarkdownTemplate("{{#if false}}\nA\n{{#elseif true\nB\n{{/if}}")).not.toThrow(); + }); + }); +}); diff --git a/pkg/workflow/template_fuzz_test.go b/pkg/workflow/template_fuzz_test.go index ff6bcf61e8..6fa5cbaed3 100644 --- a/pkg/workflow/template_fuzz_test.go +++ b/pkg/workflow/template_fuzz_test.go @@ -103,6 +103,30 @@ func FuzzWrapExpressionsInTemplateConditionals(f *testing.F) { f.Add("Before {{#if github.actor}}middle{{/if}} after") f.Add("{{#if github.actor}}{{#if github.repository}}nested{{/if}}{{/if}}") + // elseif variants — all 6 syntax forms + f.Add("{{#if github.actor}}A{{#elseif github.repository}}B{{/if}}") + f.Add("{{#if github.actor}}A{{#else-if github.repository}}B{{/if}}") + f.Add("{{#if github.actor}}A{{#else_if github.repository}}B{{/if}}") + f.Add("{{#if github.actor}}A{{elseif github.repository}}B{{/if}}") + f.Add("{{#if github.actor}}A{{else-if github.repository}}B{{/if}}") + f.Add("{{#if github.actor}}A{{else_if github.repository}}B{{/if}}") + + // elseif with already-wrapped expressions (should be preserved) + f.Add("{{#if github.actor}}A{{#elseif ${{ github.repository }} }}B{{/if}}") + + // elseif with env var reference (should be preserved, not re-wrapped) + f.Add("{{#if github.actor}}A{{#elseif ${GH_AW_EXPR_REPO}}}B{{/if}}") + + // elseif with placeholder reference (should be preserved) + f.Add("{{#if github.actor}}A{{#elseif __PLACEHOLDER__}}B{{/if}}") + + // multiple elseif branches + f.Add("{{#if a}}A{{#elseif b}}B{{#elseif c}}C{{/if}}") + + // elseif + else + f.Add("{{#if a}}A{{#elseif b}}B{{#else}}C{{/if}}") + f.Add("{{#if github.actor}}\nA\n{{#elseif github.repository}}\nB\n{{#else}}\nC\n{{/if}}") + f.Fuzz(func(t *testing.T, input string) { // The fuzzer will generate variations of the seed corpus // and random strings to test the wrapper @@ -149,5 +173,16 @@ func FuzzWrapExpressionsInTemplateConditionals(f *testing.F) { t.Errorf("Placeholder references should be preserved, input: %q, result: %q", input, result) } } + + // All elseif syntax variants must be normalised to canonical {{#elseif in the output + nonCanonicalElseif := []string{ + "{{#else-if ", "{{#else_if ", + "{{elseif ", "{{else-if ", "{{else_if ", + } + for _, variant := range nonCanonicalElseif { + if strings.Contains(result, variant) { + t.Errorf("Non-canonical elseif variant %q still present in output, input: %q", variant, input) + } + } }) }