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
5 changes: 5 additions & 0 deletions .changeset/patch-clean-substitute-placeholders.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 18 additions & 79 deletions actions/setup/js/substitute_placeholders.cjs
Original file line number Diff line number Diff line change
@@ -1,117 +1,56 @@
// @ts-check
/// <reference types="@actions/github-script" />

// Ensures global.core is available when running outside github-script context
require("./shim.cjs");
const fs = require("fs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { ERR_SYSTEM } = require("./error_codes.cjs");

/**
* Substitutes `__KEY__` placeholders in a file with values from the substitutions map.
* Undefined/null values are treated as empty strings.
*
* @param {{ file: string, substitutions: Record<string, string | null | undefined> }} params
* @returns {Promise<string>}
*/
const substitutePlaceholders = async ({ file, substitutions }) => {
if (typeof core !== "undefined") {
core.info("========================================");
core.info("[substitutePlaceholders] Starting placeholder substitution");
core.info("========================================");
}

// Validate parameters
if (!file) {
const error = new Error("file parameter is required");
if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] ERROR: ${error.message}`);
}
throw error;
throw new Error("file parameter is required");
}
if (!substitutions || "object" != typeof substitutions) {
const error = new Error("substitutions parameter must be an object");
if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] ERROR: ${error.message}`);
}
throw error;
if (!substitutions || typeof substitutions !== "object") {
throw new Error("substitutions parameter must be an object");
}

if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] File: ${file}`);
core.info(`[substitutePlaceholders] Substitution count: ${Object.keys(substitutions).length}`);
}
core.info(`[substitutePlaceholders] ${file} (${Object.keys(substitutions).length} substitution(s))`);

// Read the file
let content;
try {
if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] Reading file...`);
}
content = fs.readFileSync(file, "utf8");
if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] File read successfully`);
core.info(`[substitutePlaceholders] Original content length: ${content.length} characters`);
core.info(`[substitutePlaceholders] First 200 characters: ${content.substring(0, 200).replace(/\n/g, "\\n")}`);
}
} catch (error) {
const errorMessage = getErrorMessage(error);
if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] ERROR reading file: ${errorMessage}`);
}
throw new Error(`${ERR_SYSTEM}: Failed to read file ${file}: ${errorMessage}`);
}

// Perform substitutions
if (typeof core !== "undefined") {
core.info("\n========================================");
core.info("[substitutePlaceholders] Processing Substitutions");
core.info("========================================");
}

let totalReplacements = 0;
const beforeLength = content.length;

for (const [key, value] of Object.entries(substitutions)) {
const placeholder = `__${key}__`;
// Convert undefined/null to empty string to avoid leaving "undefined" or "null" in the output
const safeValue = value === undefined || value === null ? "" : value;

// Count occurrences before replacement
const occurrences = (content.match(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;

if (typeof core !== "undefined") {
if (occurrences > 0) {
core.info(`[substitutePlaceholders] Replacing ${placeholder} (${occurrences} occurrence(s))`);
core.info(`[substitutePlaceholders] Value: ${safeValue.substring(0, 100)}${safeValue.length > 100 ? "..." : ""}`);
} else {
core.info(`[substitutePlaceholders] Placeholder ${placeholder} not found in content (unused)`);
}
}

const safeValue = value == null ? "" : value;
content = content.split(placeholder).join(safeValue);
totalReplacements += occurrences;
}

const afterLength = content.length;

if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] Substitution complete: ${totalReplacements} total replacement(s)`);
core.info(`[substitutePlaceholders] Content length change: ${beforeLength} -> ${afterLength} (${afterLength > beforeLength ? "+" : ""}${afterLength - beforeLength})`);
}

// Write back to the file
try {
if (typeof core !== "undefined") {
core.info("\n========================================");
core.info("[substitutePlaceholders] Writing Output");
core.info("========================================");
core.info(`[substitutePlaceholders] Writing processed content back to: ${file}`);
}
fs.writeFileSync(file, content, "utf8");
if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] File written successfully`);
core.info(`[substitutePlaceholders] Last 200 characters: ${content.substring(Math.max(0, content.length - 200)).replace(/\n/g, "\\n")}`);
core.info("========================================");
core.info("[substitutePlaceholders] Processing complete - SUCCESS");
core.info("========================================");
}
} catch (error) {
const errorMessage = getErrorMessage(error);
if (typeof core !== "undefined") {
core.info(`[substitutePlaceholders] ERROR writing file: ${errorMessage}`);
}
throw new Error(`${ERR_SYSTEM}: Failed to write file ${file}: ${errorMessage}`);
}

return `Successfully substituted ${Object.keys(substitutions).length} placeholder(s) in ${file}`;
};

module.exports = substitutePlaceholders;
163 changes: 98 additions & 65 deletions actions/setup/js/substitute_placeholders.test.cjs
Original file line number Diff line number Diff line change
@@ -1,67 +1,100 @@
const fs = require("fs"),
os = require("os"),
path = require("path"),
substitutePlaceholders = require("./substitute_placeholders.cjs");
import { afterEach, beforeEach, describe, expect, it } from "vitest";

const fs = require("fs");
const os = require("os");
const path = require("path");
const substitutePlaceholders = require("./substitute_placeholders.cjs");

describe("substitutePlaceholders", () => {
let tempDir, testFile;
(beforeEach(() => {
((tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "substitute-test-"))), (testFile = path.join(tempDir, "test.txt")));
}),
afterEach(() => {
(fs.existsSync(testFile) && fs.unlinkSync(testFile), fs.existsSync(tempDir) && fs.rmdirSync(tempDir));
}),
it("should substitute a single placeholder", async () => {
(fs.writeFileSync(testFile, "Hello __NAME__!", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { NAME: "World" } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("Hello World!");
}),
it("should substitute multiple placeholders", async () => {
(fs.writeFileSync(testFile, "Repository: __REPO__\nActor: __ACTOR__\nBranch: __BRANCH__", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { REPO: "test/repo", ACTOR: "testuser", BRANCH: "main" } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("Repository: test/repo\nActor: testuser\nBranch: main");
}),
it("should handle special characters safely", async () => {
(fs.writeFileSync(testFile, "Command: __CMD__", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { CMD: "$(malicious) `backdoor` ${VAR} | pipe" } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("Command: $(malicious) `backdoor` ${VAR} | pipe");
}),
it("should handle placeholders appearing multiple times", async () => {
(fs.writeFileSync(testFile, "__NAME__ is great. I love __NAME__!", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { NAME: "Testing" } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("Testing is great. I love Testing!");
}),
it("should leave unmatched placeholders unchanged", async () => {
(fs.writeFileSync(testFile, "__FOO__ and __BAR__", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { FOO: "foo" } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("foo and __BAR__");
}),
it("should handle empty values", async () => {
(fs.writeFileSync(testFile, "Value: __VAL__", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { VAL: "" } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("Value: ");
}),
it("should throw error if file parameter is missing", async () => {
await expect(substitutePlaceholders({ substitutions: { NAME: "test" } })).rejects.toThrow("file parameter is required");
}),
it("should throw error if substitutions parameter is missing", async () => {
await expect(substitutePlaceholders({ file: testFile })).rejects.toThrow("substitutions parameter must be an object");
}),
it("should throw error if file does not exist", async () => {
await expect(substitutePlaceholders({ file: "/nonexistent/file.txt", substitutions: { NAME: "test" } })).rejects.toThrow("Failed to read file");
}),
it("should handle undefined values as empty strings", async () => {
(fs.writeFileSync(testFile, "Value: __VAL__", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { VAL: undefined } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("Value: ");
}),
it("should handle null values as empty strings", async () => {
(fs.writeFileSync(testFile, "Value: __VAL__", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { VAL: null } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("Value: ");
}),
it("should handle mixed undefined and defined values", async () => {
(fs.writeFileSync(testFile, "Repo: __REPO__\nComment: __COMMENT__\nIssue: __ISSUE__", "utf8"), await substitutePlaceholders({ file: testFile, substitutions: { REPO: "test/repo", COMMENT: undefined, ISSUE: null } }));
const content = fs.readFileSync(testFile, "utf8");
expect(content).toBe("Repo: test/repo\nComment: \nIssue: ");
}));
/** @type {string} */
let tempDir;
/** @type {string} */
let testFile;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "substitute-test-"));
testFile = path.join(tempDir, "test.txt");
});

afterEach(() => {
if (fs.existsSync(testFile)) fs.unlinkSync(testFile);
if (fs.existsSync(tempDir)) fs.rmdirSync(tempDir);
});
Comment thread
github-actions[bot] marked this conversation as resolved.

it("should substitute a single placeholder", async () => {
fs.writeFileSync(testFile, "Hello __NAME__!", "utf8");
await substitutePlaceholders({ file: testFile, substitutions: { NAME: "World" } });
expect(fs.readFileSync(testFile, "utf8")).toBe("Hello World!");
});

it("should substitute multiple placeholders", async () => {
fs.writeFileSync(testFile, "Repository: __REPO__\nActor: __ACTOR__\nBranch: __BRANCH__", "utf8");
await substitutePlaceholders({
file: testFile,
substitutions: { REPO: "test/repo", ACTOR: "testuser", BRANCH: "main" },
});
expect(fs.readFileSync(testFile, "utf8")).toBe("Repository: test/repo\nActor: testuser\nBranch: main");
});

it("should handle special characters safely", async () => {
fs.writeFileSync(testFile, "Command: __CMD__", "utf8");
await substitutePlaceholders({
file: testFile,
substitutions: { CMD: "$(malicious) `backdoor` ${VAR} | pipe" },
});
expect(fs.readFileSync(testFile, "utf8")).toBe("Command: $(malicious) `backdoor` ${VAR} | pipe");
});

it("should handle placeholders appearing multiple times", async () => {
fs.writeFileSync(testFile, "__NAME__ is great. I love __NAME__!", "utf8");
await substitutePlaceholders({ file: testFile, substitutions: { NAME: "Testing" } });
expect(fs.readFileSync(testFile, "utf8")).toBe("Testing is great. I love Testing!");
});

it("should leave unmatched placeholders unchanged", async () => {
fs.writeFileSync(testFile, "__FOO__ and __BAR__", "utf8");
await substitutePlaceholders({ file: testFile, substitutions: { FOO: "foo" } });
expect(fs.readFileSync(testFile, "utf8")).toBe("foo and __BAR__");
});

it("should handle empty values", async () => {
fs.writeFileSync(testFile, "Value: __VAL__", "utf8");
await substitutePlaceholders({ file: testFile, substitutions: { VAL: "" } });
expect(fs.readFileSync(testFile, "utf8")).toBe("Value: ");
});

it("should throw error if file parameter is missing", async () => {
// @ts-expect-error - testing missing file param
await expect(substitutePlaceholders({ substitutions: { NAME: "test" } })).rejects.toThrow("file parameter is required");
});

it("should throw error if substitutions parameter is missing", async () => {
// @ts-expect-error - testing missing substitutions param
await expect(substitutePlaceholders({ file: testFile })).rejects.toThrow("substitutions parameter must be an object");
});

it("should throw error if file does not exist", async () => {
await expect(substitutePlaceholders({ file: "/nonexistent/file.txt", substitutions: { NAME: "test" } })).rejects.toThrow("Failed to read file");
});

it("should handle undefined values as empty strings", async () => {
fs.writeFileSync(testFile, "Value: __VAL__", "utf8");
await substitutePlaceholders({ file: testFile, substitutions: { VAL: undefined } });
expect(fs.readFileSync(testFile, "utf8")).toBe("Value: ");
});

it("should handle null values as empty strings", async () => {
fs.writeFileSync(testFile, "Value: __VAL__", "utf8");
await substitutePlaceholders({ file: testFile, substitutions: { VAL: null } });
expect(fs.readFileSync(testFile, "utf8")).toBe("Value: ");
});

it("should handle mixed undefined and defined values", async () => {
fs.writeFileSync(testFile, "Repo: __REPO__\nComment: __COMMENT__\nIssue: __ISSUE__", "utf8");
await substitutePlaceholders({
file: testFile,
substitutions: { REPO: "test/repo", COMMENT: undefined, ISSUE: null },
});
expect(fs.readFileSync(testFile, "utf8")).toBe("Repo: test/repo\nComment: \nIssue: ");
});
});
Loading