Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 144 additions & 8 deletions actions/setup/js/interpolate_prompt.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,29 @@ const { getErrorMessage } = require("./error_helpers.cjs");
* @returns {string} - The interpolated content
*/
function interpolateVariables(content, variables) {
core.info(`[interpolateVariables] Starting interpolation with ${Object.keys(variables).length} variables`);
core.info(`[interpolateVariables] Content length: ${content.length} characters`);

let result = content;
let totalReplacements = 0;

// Replace each ${VAR_NAME} with its corresponding value
for (const [varName, value] of Object.entries(variables)) {
const pattern = new RegExp(`\\$\\{${varName}\\}`, "g");
result = result.replace(pattern, value);
const matches = (content.match(pattern) || []).length;

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);
totalReplacements += matches;
} else {
core.info(`[interpolateVariables] Variable ${varName} not found in content (unused)`);
}
Comment on lines 27 to +38
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interpolateVariables() now only runs result.replace() when matches > 0, but matches is computed from the original content. If an earlier replacement introduces a new ${VAR} placeholder (e.g., a variable value contains another placeholder), later variables may be incorrectly treated as “unused” and skipped, leaving placeholders un-interpolated. Compute matches from result (current working string) and/or perform the replacement unconditionally to preserve previous behavior.

Copilot uses AI. Check for mistakes.
}

core.info(`[interpolateVariables] Completed: ${totalReplacements} total replacement(s)`);
core.info(`[interpolateVariables] Result length: ${result.length} characters`);
return result;
}

Expand All @@ -37,24 +52,81 @@ function interpolateVariables(content, variables) {
* @returns {string} - The processed markdown content
*/
function renderMarkdownTemplate(markdown) {
core.info(`[renderMarkdownTemplate] Starting template rendering`);
core.info(`[renderMarkdownTemplate] Input length: ${markdown.length} characters`);

// Count conditionals before processing
const blockConditionals = (markdown.match(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g) || []).length;
const inlineConditionals = (markdown.match(/{{#if\s+([^}]*)}}([\s\S]*?){{\/if}}/g) || []).length - blockConditionals;

core.info(`[renderMarkdownTemplate] Found ${blockConditionals} block conditional(s) and ${inlineConditionals} inline conditional(s)`);

let blockCount = 0;
let keptBlocks = 0;
let removedBlocks = 0;

// First pass: Handle blocks where tags are on their own lines
// Captures: (leading newline)(opening tag line)(condition)(body)(closing tag line)(trailing newline)
let result = markdown.replace(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body, closeLine, trailNL) => {
if (isTruthy(cond)) {
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] Body preview: "${bodyPreview}${body.length > 60 ? "..." : ""}"`);

if (truthyResult) {
// Keep body with leading newline if there was one before the opening tag
keptBlocks++;
core.info(`[renderMarkdownTemplate] Action: Keeping body with leading newline=${!!leadNL}`);
return leadNL + body;
} else {
// Remove entire block completely - the line containing the template is removed
removedBlocks++;
core.info(`[renderMarkdownTemplate] Action: Removing entire block`);
return "";
}
});

core.info(`[renderMarkdownTemplate] First pass complete: ${keptBlocks} kept, ${removedBlocks} removed`);

let inlineCount = 0;
let keptInline = 0;
let removedInline = 0;

// Second pass: Handle inline conditionals (tags not on their own lines)
result = result.replace(/{{#if\s+([^}]*)}}([\s\S]*?){{\/if}}/g, (_, cond, body) => (isTruthy(cond) ? body : ""));
result = result.replace(/{{#if\s+([^}]*)}}([\s\S]*?){{\/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"}`);
core.info(`[renderMarkdownTemplate] Body preview: "${bodyPreview}${body.length > 40 ? "..." : ""}"`);

if (truthyResult) {
keptInline++;
return body;
} else {
removedInline++;
return "";
}
});

core.info(`[renderMarkdownTemplate] Second pass complete: ${keptInline} kept, ${removedInline} removed`);

// Clean up excessive blank lines (more than one blank line = 2 newlines)
const beforeCleanup = result.length;
const excessiveLines = (result.match(/\n{3,}/g) || []).length;
result = result.replace(/\n{3,}/g, "\n\n");

if (excessiveLines > 0) {
core.info(`[renderMarkdownTemplate] Cleaned up ${excessiveLines} excessive blank line sequence(s)`);
core.info(`[renderMarkdownTemplate] Length change from cleanup: ${beforeCleanup} -> ${result.length} characters`);
}
core.info(`[renderMarkdownTemplate] Final output length: ${result.length} characters`);

return result;
}

Expand All @@ -63,33 +135,58 @@ function renderMarkdownTemplate(markdown) {
*/
async function main() {
try {
core.info("========================================");
core.info("[main] Starting interpolate_prompt processing");
core.info("========================================");

const promptPath = process.env.GH_AW_PROMPT;
if (!promptPath) {
core.setFailed("GH_AW_PROMPT environment variable is not set");
return;
}
core.info(`[main] Prompt path: ${promptPath}`);

// Get the workspace directory for runtime imports
const workspaceDir = process.env.GITHUB_WORKSPACE;
if (!workspaceDir) {
core.setFailed("GITHUB_WORKSPACE environment variable is not set");
return;
}
core.info(`[main] Workspace directory: ${workspaceDir}`);

// Read the prompt file
core.info(`[main] Reading prompt file...`);
let content = fs.readFileSync(promptPath, "utf8");
const originalLength = content.length;
core.info(`[main] Original content length: ${originalLength} characters`);
core.info(`[main] First 200 characters: ${content.substring(0, 200).replace(/\n/g, "\\n")}`);
Comment on lines +160 to +162
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core.info logs the first 200 characters of the prompt ([main] First 200 characters: ...). This can leak sensitive data into workflow logs (prompt may include user content or runtime-imported data). Consider removing content previews by default, redacting, or gating behind an explicit debug flag / core.debug.

This issue also appears in the following locations of the same file:

  • line 200
  • line 74
  • line 31
  • line 245

Copilot uses AI. Check for mistakes.

// Step 1: Process runtime imports (files and URLs)
core.info("\n========================================");
core.info("[main] STEP 1: Runtime Imports");
core.info("========================================");
const hasRuntimeImports = /{{#runtime-import\??[ \t]+[^\}]+}}/.test(content);
if (hasRuntimeImports) {
core.info("Processing runtime import macros (files and URLs)");
const importMatches = content.match(/{{#runtime-import\??[ \t]+[^\}]+}}/g) || [];
core.info(`Processing ${importMatches.length} runtime import macro(s) (files and URLs)`);
importMatches.forEach((match, i) => {
core.info(` Import ${i + 1}: ${match.substring(0, 80)}${match.length > 80 ? "..." : ""}`);
});

const beforeImports = content.length;
content = await processRuntimeImports(content, workspaceDir);
core.info("Runtime imports processed successfully");
const afterImports = content.length;

core.info(`Runtime imports processed successfully`);
core.info(`Content length change: ${beforeImports} -> ${afterImports} (${afterImports > beforeImports ? "+" : ""}${afterImports - beforeImports})`);
} else {
core.info("No runtime import macros found, skipping runtime import processing");
}

// Step 2: Interpolate variables
core.info("\n========================================");
core.info("[main] STEP 2: Variable Interpolation");
core.info("========================================");
/** @type {Record<string, string>} */
const variables = {};
for (const [key, value] of Object.entries(process.env)) {
Expand All @@ -100,26 +197,65 @@ async function main() {

const varCount = Object.keys(variables).length;
if (varCount > 0) {
core.info(`Found ${varCount} expression variable(s) to interpolate`);
core.info(`Found ${varCount} expression variable(s) to interpolate:`);
for (const [key, value] of Object.entries(variables)) {
const preview = value.substring(0, 60);
core.info(` ${key}: ${preview}${value.length > 60 ? "..." : ""}`);
}

const beforeInterpolation = content.length;
content = interpolateVariables(content, variables);
const afterInterpolation = content.length;

core.info(`Successfully interpolated ${varCount} variable(s) in prompt`);
core.info(`Content length change: ${beforeInterpolation} -> ${afterInterpolation} (${afterInterpolation > beforeInterpolation ? "+" : ""}${afterInterpolation - beforeInterpolation})`);
} else {
core.info("No expression variables found, skipping interpolation");
}

// Step 3: Render template conditionals
core.info("\n========================================");
core.info("[main] STEP 3: Template Rendering");
core.info("========================================");
const hasConditionals = /{{#if\s+[^}]+}}/.test(content);
if (hasConditionals) {
core.info("Processing conditional template blocks");
const conditionalMatches = content.match(/{{#if\s+[^}]+}}/g) || [];
core.info(`Processing ${conditionalMatches.length} conditional template block(s)`);

const beforeRendering = content.length;
content = renderMarkdownTemplate(content);
core.info("Template rendered successfully");
const afterRendering = content.length;

core.info(`Template rendered successfully`);
core.info(`Content length change: ${beforeRendering} -> ${afterRendering} (${afterRendering > beforeRendering ? "+" : ""}${afterRendering - beforeRendering})`);
} else {
core.info("No conditional blocks found in prompt, skipping template rendering");
}

// Write back to the same file
core.info("\n========================================");
core.info("[main] STEP 4: Writing Output");
core.info("========================================");
core.info(`Writing processed content back to: ${promptPath}`);
core.info(`Final content length: ${content.length} characters`);
core.info(`Total length change: ${originalLength} -> ${content.length} (${content.length > originalLength ? "+" : ""}${content.length - originalLength})`);

fs.writeFileSync(promptPath, content, "utf8");

core.info(`Last 200 characters: ${content.substring(Math.max(0, content.length - 200)).replace(/\n/g, "\\n")}`);
core.info("========================================");
core.info("[main] Processing complete - SUCCESS");
core.info("========================================");
} catch (error) {
core.info("========================================");
core.info("[main] Processing failed - ERROR");
core.info("========================================");
const err = error instanceof Error ? error : new Error(String(error));
core.info(`[main] Error type: ${err.constructor.name}`);
core.info(`[main] Error message: ${err.message}`);
if (err.stack) {
core.info(`[main] Stack trace:\n${err.stack}`);
}
core.setFailed(getErrorMessage(error));
}
}
Expand Down
6 changes: 4 additions & 2 deletions actions/setup/js/interpolate_prompt_additional.test.cjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url),
__dirname = path.dirname(__filename),
{ isTruthy } = require("./is_truthy.cjs"),
core = { info: vi.fn(), setFailed: vi.fn() };
global.core = core;
const { isTruthy } = require("./is_truthy.cjs"),
interpolatePromptScript = fs.readFileSync(path.join(__dirname, "interpolate_prompt.cjs"), "utf8"),
renderMarkdownTemplateMatch = interpolatePromptScript.match(/function renderMarkdownTemplate\(markdown\)\s*{[\s\S]*?return result;[\s\S]*?}/);
if (!renderMarkdownTemplateMatch) throw new Error("Could not extract renderMarkdownTemplate function from interpolate_prompt.cjs");
Expand Down
Loading