diff --git a/actions/setup/js/interpolate_prompt.cjs b/actions/setup/js/interpolate_prompt.cjs index 2d7da5adbea..abc28766f1f 100644 --- a/actions/setup/js/interpolate_prompt.cjs +++ b/actions/setup/js/interpolate_prompt.cjs @@ -37,7 +37,7 @@ function interpolateVariables(content, variables) { if (matches > 0) { core.info(`[interpolateVariables] Replacing ${varName} (${matches} occurrence(s))`); core.info(`[interpolateVariables] Value: ${value.substring(0, 100)}${value.length > 100 ? "..." : ""}`); - result = result.replace(pattern, value); + result = result.replace(pattern, () => value); totalReplacements += matches; } else { core.info(`[interpolateVariables] Variable ${varName} not found in content (unused)`); @@ -298,7 +298,7 @@ async function main() { const conditionPattern = new RegExp(`(\\{\\{#if[^}]*?)${exprForm.replace(".", "\\.")}`, "gi"); if (conditionPattern.test(content)) { conditionPattern.lastIndex = 0; - content = content.replace(conditionPattern, `$1${value || ""}`); + content = content.replace(conditionPattern, (_, prefix) => prefix + (value || "")); core.info(` Substituted ${exprForm} in conditions → "${value || ""}"`); } } diff --git a/actions/setup/js/interpolate_prompt.test.cjs b/actions/setup/js/interpolate_prompt.test.cjs index e2a2ebdd965..a6b849392b5 100644 --- a/actions/setup/js/interpolate_prompt.test.cjs +++ b/actions/setup/js/interpolate_prompt.test.cjs @@ -46,6 +46,26 @@ describe("interpolate_prompt", () => { it("should handle content with literal dollar signs", () => { const result = interpolateVariables("Price: $100, Repo: ${GH_AW_EXPR_REPO}", { GH_AW_EXPR_REPO: "github/test-repo" }); expect(result).toBe("Price: $100, Repo: github/test-repo"); + }), + it("should not corrupt output when value contains $$ (special replacement pattern)", () => { + const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "cost is $$100" }); + expect(result).toBe("Value: cost is $$100"); + }), + it("should not corrupt output when value contains $& (matched substring pattern)", () => { + const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "see $& for details" }); + expect(result).toBe("Value: see $& for details"); + }), + it("should not corrupt output when value contains $` (before-match pattern)", () => { + const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "use $`cmd` to run" }); + expect(result).toBe("Value: use $`cmd` to run"); + }), + it("should not corrupt output when value contains $' (after-match pattern)", () => { + const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "it's $'quoted'" }); + expect(result).toBe("Value: it's $'quoted'"); + }), + it("should not corrupt output when value contains $1 (capture group pattern)", () => { + const result = interpolateVariables("Value: ${GH_AW_EXPR_BODY}", { GH_AW_EXPR_BODY: "group $1 matched" }); + expect(result).toBe("Value: group $1 matched"); })); }), describe("renderMarkdownTemplate", () => { @@ -175,6 +195,28 @@ describe("interpolate_prompt", () => { }), describe("main function integration", () => { let tmpDir, promptPath, originalEnv; + /** + * Apply the STEP 2.5 experiment condition substitution logic from main(). + * Reads GH_AW_EXPERIMENTS_* from process.env and substitutes experiments.name + * references inside {{#if}} conditions, using a replacer function to prevent + * special $ replacement patterns from corrupting the output. + * @param {string} content + * @returns {string} + */ + function applyExperimentSubstitution(content) { + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith("GH_AW_EXPERIMENTS_")) { + const experimentName = key.substring("GH_AW_EXPERIMENTS_".length).toLowerCase(); + const exprForm = `experiments.${experimentName}`; + const conditionPattern = new RegExp(`(\\{\\{#if[^}]*?)${exprForm.replace(".", "\\.")}`, "gi"); + if (conditionPattern.test(content)) { + conditionPattern.lastIndex = 0; + content = content.replace(conditionPattern, (_, prefix) => prefix + (value || "")); + } + } + } + return content; + } (beforeEach(() => { ((originalEnv = { ...process.env }), (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "interpolate-test-"))), @@ -196,6 +238,24 @@ describe("interpolate_prompt", () => { if (!mainMatch) throw new Error("Could not extract main function"); const main = eval(`(${mainMatch[0]})`); (main(), expect(core.setFailed).toHaveBeenCalledWith(`${ERR_CONFIG}: GH_AW_PROMPT environment variable is not set`)); + }), + it("should not corrupt condition when experiment value contains $1 (STEP 2.5)", () => { + // Regression: GH_AW_EXPERIMENTS_* value containing $1 must not be interpreted as a + // capture-group reference when substituting experiments.name inside {{#if}} conditions. + process.env.GH_AW_EXPERIMENTS_STYLE = "$1bold"; + const content = applyExperimentSubstitution("{{#if experiments.style}}\nSelected\n{{#else}}\nNot selected\n{{#endif}}"); + const result = renderMarkdownTemplate(content); + expect(result).toContain("Selected"); + expect(result).not.toContain("Not selected"); + }), + it("should not corrupt condition when experiment value contains $& (STEP 2.5)", () => { + // Regression: GH_AW_EXPERIMENTS_* value containing $& must not be interpreted as the + // matched-substring pattern when substituting inside {{#if}} conditions. + process.env.GH_AW_EXPERIMENTS_STYLE = "$&matched"; + const content = applyExperimentSubstitution("{{#if experiments.style}}\nSelected\n{{#else}}\nNot selected\n{{#endif}}"); + const result = renderMarkdownTemplate(content); + expect(result).toContain("Selected"); + expect(result).not.toContain("Not selected"); })); })); }); diff --git a/actions/setup/js/runtime_import.cjs b/actions/setup/js/runtime_import.cjs index 6d8484a6438..80965d1f0d2 100644 --- a/actions/setup/js/runtime_import.cjs +++ b/actions/setup/js/runtime_import.cjs @@ -1127,7 +1127,7 @@ async function processRuntimeImports(content, workspaceDir, importedFiles = new // Reuse cached content const cachedContent = importCache.get(filepathWithRange); if (cachedContent !== undefined) { - processedContent = processedContent.replace(fullMatch, cachedContent); + processedContent = processedContent.replace(fullMatch, () => cachedContent); core.info(`Reusing cached content for ${filepathWithRange}`); continue; } @@ -1159,7 +1159,7 @@ async function processRuntimeImports(content, workspaceDir, importedFiles = new importedFiles.add(filepathWithRange); // Replace the macro with the imported content - processedContent = processedContent.replace(fullMatch, importedContent); + processedContent = processedContent.replace(fullMatch, () => importedContent); } catch (error) { const errorMessage = getErrorMessage(error); throw new Error(`${ERR_API}: Failed to process runtime import for ${filepathWithRange}: ${errorMessage}`); diff --git a/actions/setup/js/runtime_import.test.cjs b/actions/setup/js/runtime_import.test.cjs index d3cce5c80e1..b8581bb4cf4 100644 --- a/actions/setup/js/runtime_import.test.cjs +++ b/actions/setup/js/runtime_import.test.cjs @@ -702,6 +702,42 @@ describe("runtime_import", () => { const result = await processRuntimeImports("{{#runtime-import import.md}}", tempDir); expect(result).toBe("Content with $pecial ch@racters!"); }), + it("should not corrupt output when imported content contains $$ (fresh-import path)", async () => { + fs.writeFileSync(path.join(workflowsDir, "dollars.md"), "cost is $$100 per unit"); + const result = await processRuntimeImports("Before\n{{#runtime-import dollars.md}}\nAfter", tempDir); + expect(result).toBe("Before\ncost is $$100 per unit\nAfter"); + }), + it("should not corrupt output when imported content contains $& (fresh-import path)", async () => { + fs.writeFileSync(path.join(workflowsDir, "ampersand.md"), "matched: $& here"); + const result = await processRuntimeImports("{{#runtime-import ampersand.md}}", tempDir); + expect(result).toBe("matched: $& here"); + }), + it("should not corrupt output when imported content contains $` (fresh-import path)", async () => { + fs.writeFileSync(path.join(workflowsDir, "backtick.md"), "run $`cmd` to execute"); + const result = await processRuntimeImports("{{#runtime-import backtick.md}}", tempDir); + expect(result).toBe("run $`cmd` to execute"); + }), + it("should not corrupt output when imported content contains $' (fresh-import path)", async () => { + fs.writeFileSync(path.join(workflowsDir, "quote.md"), "value is $'quoted'"); + const result = await processRuntimeImports("{{#runtime-import quote.md}}", tempDir); + expect(result).toBe("value is $'quoted'"); + }), + it("should not corrupt output when imported content contains $1 (fresh-import path)", async () => { + fs.writeFileSync(path.join(workflowsDir, "capgroup.md"), "group $1 matched"); + const result = await processRuntimeImports("{{#runtime-import capgroup.md}}", tempDir); + expect(result).toBe("group $1 matched"); + }), + it("should not corrupt output when cached imported content contains $$ (cache-hit path)", async () => { + fs.writeFileSync(path.join(workflowsDir, "cached-dollars.md"), "price: $$50"); + // Import twice so the second use exercises the cache-hit code path + const result = await processRuntimeImports("{{#runtime-import cached-dollars.md}}\n{{#runtime-import cached-dollars.md}}", tempDir); + expect(result).toBe("price: $$50\nprice: $$50"); + }), + it("should not corrupt output when cached imported content contains $` (cache-hit path)", async () => { + fs.writeFileSync(path.join(workflowsDir, "cached-backtick.md"), "run $`cmd`"); + const result = await processRuntimeImports("{{#runtime-import cached-backtick.md}}\n{{#runtime-import cached-backtick.md}}", tempDir); + expect(result).toBe("run $`cmd`\nrun $`cmd`"); + }), it("should remove XML comments from imported content", async () => { fs.writeFileSync(path.join(workflowsDir, "with-comment.md"), "Text \x3c!-- comment --\x3e more text"); const result = await processRuntimeImports("{{#runtime-import with-comment.md}}", tempDir);