diff --git a/actions/setup/js/generate_workflow_overview.cjs b/actions/setup/js/generate_workflow_overview.cjs
index f80e7b41dd..126599a332 100644
--- a/actions/setup/js/generate_workflow_overview.cjs
+++ b/actions/setup/js/generate_workflow_overview.cjs
@@ -1,6 +1,8 @@
// @ts-check
///
+const { jsonObjectToMarkdown } = require("./json_object_to_markdown.cjs");
+
/**
* Generate workflow overview step that writes an agentic workflow run overview
* to the GitHub step summary. This reads from aw_info.json that was created by
@@ -16,36 +18,15 @@ async function generateWorkflowOverview(core) {
// Load aw_info.json
const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8"));
- let networkDetails = "";
- if (awInfo.allowed_domains && awInfo.allowed_domains.length > 0) {
- networkDetails = awInfo.allowed_domains
- .slice(0, 10)
- .map(d => ` - ${d}`)
- .join("\n");
- if (awInfo.allowed_domains.length > 10) {
- networkDetails += `\n - ... and ${awInfo.allowed_domains.length - 10} more`;
- }
- }
+ // Build the collapsible summary label with engine_id and version
+ const engineLabel = [awInfo.engine_id, awInfo.version].filter(Boolean).join(" ");
+ const summaryLabel = engineLabel ? `Run details - ${engineLabel}` : "Run details";
+
+ // Render all aw_info fields as markdown bullet points
+ const details = jsonObjectToMarkdown(awInfo);
// Build summary using string concatenation to avoid YAML parsing issues with template literals
- const summary =
- "\n" +
- "Run details
\n\n" +
- "#### Engine Configuration\n" +
- "| Property | Value |\n" +
- "|----------|-------|\n" +
- `| Engine ID | ${awInfo.engine_id} |\n` +
- `| Engine Name | ${awInfo.engine_name} |\n` +
- `| Model | ${awInfo.model || "(default)"} |\n` +
- "\n" +
- "#### Network Configuration\n" +
- "| Property | Value |\n" +
- "|----------|-------|\n" +
- `| Firewall | ${awInfo.firewall_enabled ? "✅ Enabled" : "❌ Disabled"} |\n` +
- `| Firewall Version | ${awInfo.awf_version || "(latest)"} |\n` +
- "\n" +
- (networkDetails ? `##### Allowed Domains\n${networkDetails}\n` : "") +
- " ";
+ const summary = "\n" + `${summaryLabel}
\n\n` + details + "\n" + " ";
await core.summary.addRaw(summary).write();
console.log("Generated workflow overview in step summary");
diff --git a/actions/setup/js/generate_workflow_overview.test.cjs b/actions/setup/js/generate_workflow_overview.test.cjs
index 2e60f77fec..c87e666af3 100644
--- a/actions/setup/js/generate_workflow_overview.test.cjs
+++ b/actions/setup/js/generate_workflow_overview.test.cjs
@@ -1,7 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
-import os from "os";
-import path from "path";
// Mock the global objects that GitHub Actions provides
const mockCore = {
@@ -22,9 +20,7 @@ global.core = mockCore;
describe("generate_workflow_overview.cjs", () => {
let generateWorkflowOverview;
- let tmpDir;
let awInfoPath;
- let originalRequireCache;
beforeEach(async () => {
// Reset mocks
@@ -54,6 +50,7 @@ describe("generate_workflow_overview.cjs", () => {
engine_id: "copilot",
engine_name: "GitHub Copilot",
model: "gpt-4",
+ version: "v1.2.3",
firewall_enabled: true,
awf_version: "1.0.0",
allowed_domains: [],
@@ -67,19 +64,22 @@ describe("generate_workflow_overview.cjs", () => {
const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
expect(summaryArg).toContain("");
- expect(summaryArg).toContain("Run details
");
- expect(summaryArg).toContain("#### Engine Configuration");
- expect(summaryArg).toContain("| Engine ID | copilot |");
- expect(summaryArg).toContain("| Engine Name | GitHub Copilot |");
- expect(summaryArg).toContain("| Model | gpt-4 |");
- expect(summaryArg).toContain("#### Network Configuration");
- expect(summaryArg).toContain("| Firewall | ✅ Enabled |");
- expect(summaryArg).toContain("| Firewall Version | 1.0.0 |");
+ // engine_id and version should appear in the summary label
+ expect(summaryArg).toContain("Run details - copilot v1.2.3
");
+ // All fields should be rendered as bullet points with humanified keys
+ expect(summaryArg).toContain("- **engine id**: copilot");
+ expect(summaryArg).toContain("- **engine name**: GitHub Copilot");
+ expect(summaryArg).toContain("- **model**: gpt-4");
+ expect(summaryArg).toContain("- **version**: v1.2.3");
+ expect(summaryArg).toContain("- **firewall enabled**: true");
+ expect(summaryArg).toContain("- **awf version**: 1.0.0");
expect(summaryArg).toContain(" ");
+ // Ensure no table syntax is present
+ expect(summaryArg).not.toContain("| Property | Value |");
+ expect(summaryArg).not.toContain("|----------|-------|");
});
- it("should handle missing optional fields with defaults", async () => {
- // Create test aw_info.json with minimal fields
+ it("should show only engine_id in summary label when version is missing", async () => {
const awInfo = {
engine_id: "claude",
engine_name: "Claude",
@@ -90,62 +90,41 @@ describe("generate_workflow_overview.cjs", () => {
await generateWorkflowOverview(mockCore);
const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
- expect(summaryArg).toContain("| Model | (default) |");
- expect(summaryArg).toContain("| Firewall | ❌ Disabled |");
- expect(summaryArg).toContain("| Firewall Version | (latest) |");
- });
-
- it("should include allowed domains when present (up to 10)", async () => {
- const awInfo = {
- engine_id: "copilot",
- engine_name: "GitHub Copilot",
- firewall_enabled: true,
- allowed_domains: ["example.com", "github.com", "api.github.com"],
- };
- fs.writeFileSync(awInfoPath, JSON.stringify(awInfo));
-
- await generateWorkflowOverview(mockCore);
-
- const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
- expect(summaryArg).toContain("##### Allowed Domains");
- expect(summaryArg).toContain(" - example.com");
- expect(summaryArg).toContain(" - github.com");
- expect(summaryArg).toContain(" - api.github.com");
+ expect(summaryArg).toContain("Run details - claude");
+ expect(summaryArg).toContain("- **engine id**: claude");
+ expect(summaryArg).toContain("- **firewall enabled**: false");
});
- it("should truncate allowed domains list when more than 10", async () => {
- const domains = Array.from({ length: 15 }, (_, i) => `domain${i + 1}.com`);
+ it("should show plain 'Run details' in summary label when both engine_id and version are missing", async () => {
const awInfo = {
- engine_id: "copilot",
- engine_name: "GitHub Copilot",
- firewall_enabled: true,
- allowed_domains: domains,
+ engine_name: "Unknown Engine",
};
fs.writeFileSync(awInfoPath, JSON.stringify(awInfo));
await generateWorkflowOverview(mockCore);
const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
- expect(summaryArg).toContain("##### Allowed Domains");
- expect(summaryArg).toContain(" - domain1.com");
- expect(summaryArg).toContain(" - domain10.com");
- expect(summaryArg).toContain(" - ... and 5 more");
- expect(summaryArg).not.toContain("domain11.com");
+ expect(summaryArg).toContain("Run details");
});
- it("should not include Allowed Domains section when empty", async () => {
+ it("should render all fields from aw_info including nested objects and arrays", async () => {
const awInfo = {
engine_id: "copilot",
- engine_name: "GitHub Copilot",
- firewall_enabled: false,
- allowed_domains: [],
+ version: "v2.0.0",
+ allowed_domains: ["example.com", "github.com"],
+ steps: { firewall: "iptables" },
};
fs.writeFileSync(awInfoPath, JSON.stringify(awInfo));
await generateWorkflowOverview(mockCore);
const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
- expect(summaryArg).not.toContain("##### Allowed Domains");
+ expect(summaryArg).toContain("- **engine id**: copilot");
+ expect(summaryArg).toContain("- **allowed domains**:");
+ expect(summaryArg).toContain(" - example.com");
+ expect(summaryArg).toContain(" - github.com");
+ expect(summaryArg).toContain("- **steps**:");
+ expect(summaryArg).toContain(" - **firewall**: iptables");
});
it("should log success message", async () => {
diff --git a/actions/setup/js/json_object_to_markdown.cjs b/actions/setup/js/json_object_to_markdown.cjs
new file mode 100644
index 0000000000..bd8f2e4c98
--- /dev/null
+++ b/actions/setup/js/json_object_to_markdown.cjs
@@ -0,0 +1,81 @@
+// @ts-check
+
+/**
+ * JSON Object to Markdown Converter
+ *
+ * Converts a plain JavaScript object to a Markdown bullet list.
+ * Handles nested objects (with indentation), arrays, and primitive values.
+ */
+
+/**
+ * Humanify a JSON key by replacing underscores and hyphens with spaces.
+ * e.g. "engine_id" → "engine id", "awf-version" → "awf version"
+ * @param {string} key - The raw object key
+ * @returns {string} - Human-readable key
+ */
+function humanifyKey(key) {
+ return key.replace(/[_-]/g, " ");
+}
+
+/**
+ * Format a single value as a readable string for Markdown output.
+ * @param {unknown} value - The value to format
+ * @returns {string} - String representation of the value
+ */
+function formatValue(value) {
+ if (value === null || value === undefined || value === "") {
+ return "(none)";
+ }
+ if (Array.isArray(value)) {
+ return value.length === 0 ? "(none)" : "";
+ }
+ if (typeof value === "object") {
+ return "";
+ }
+ return String(value);
+}
+
+/**
+ * Convert a plain JavaScript object to Markdown bullet points.
+ * Nested objects and arrays are rendered as indented sub-lists.
+ *
+ * @param {Record} obj - The object to render
+ * @param {number} [depth=0] - Current indentation depth
+ * @returns {string} - Markdown bullet list string
+ */
+function jsonObjectToMarkdown(obj, depth = 0) {
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
+ return "";
+ }
+
+ const indent = " ".repeat(depth);
+ const lines = [];
+
+ for (const [key, value] of Object.entries(obj)) {
+ const label = humanifyKey(key);
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ lines.push(`${indent}- **${label}**: (none)`);
+ } else {
+ lines.push(`${indent}- **${label}**:`);
+ for (const item of value) {
+ if (typeof item === "object" && item !== null) {
+ lines.push(jsonObjectToMarkdown(/** @type {Record} */ item, depth + 1));
+ } else {
+ lines.push(`${" ".repeat(depth + 1)}- ${String(item)}`);
+ }
+ }
+ }
+ } else if (typeof value === "object" && value !== null) {
+ lines.push(`${indent}- **${label}**:`);
+ lines.push(jsonObjectToMarkdown(/** @type {Record} */ value, depth + 1));
+ } else {
+ const formatted = formatValue(value);
+ lines.push(`${indent}- **${label}**: ${formatted}`);
+ }
+ }
+
+ return lines.join("\n");
+}
+
+module.exports = { humanifyKey, jsonObjectToMarkdown };
diff --git a/actions/setup/js/json_object_to_markdown.test.cjs b/actions/setup/js/json_object_to_markdown.test.cjs
new file mode 100644
index 0000000000..5662295e70
--- /dev/null
+++ b/actions/setup/js/json_object_to_markdown.test.cjs
@@ -0,0 +1,79 @@
+import { describe, it, expect } from "vitest";
+
+const { humanifyKey, jsonObjectToMarkdown } = await import("./json_object_to_markdown.cjs");
+
+describe("json_object_to_markdown.cjs", () => {
+ describe("humanifyKey", () => {
+ it("should replace underscores with spaces", () => {
+ expect(humanifyKey("engine_id")).toBe("engine id");
+ expect(humanifyKey("firewall_enabled")).toBe("firewall enabled");
+ expect(humanifyKey("awf_version")).toBe("awf version");
+ });
+
+ it("should replace hyphens with spaces", () => {
+ expect(humanifyKey("run-id")).toBe("run id");
+ expect(humanifyKey("awf-version")).toBe("awf version");
+ });
+
+ it("should leave keys without separators unchanged", () => {
+ expect(humanifyKey("version")).toBe("version");
+ expect(humanifyKey("model")).toBe("model");
+ });
+ });
+
+ it("should render flat key-value pairs with humanified keys", () => {
+ const obj = { engine_id: "copilot", version: "v1.0.0" };
+ const result = jsonObjectToMarkdown(obj);
+ expect(result).toContain("- **engine id**: copilot");
+ expect(result).toContain("- **version**: v1.0.0");
+ });
+
+ it("should render boolean values as true/false strings", () => {
+ const obj = { firewall_enabled: true, staged: false };
+ const result = jsonObjectToMarkdown(obj);
+ expect(result).toContain("- **firewall enabled**: true");
+ expect(result).toContain("- **staged**: false");
+ });
+
+ it("should render null/undefined/empty string values as (none)", () => {
+ const obj = { model: "", awf_version: null, agent_version: undefined };
+ const result = jsonObjectToMarkdown(obj);
+ expect(result).toContain("- **model**: (none)");
+ expect(result).toContain("- **awf version**: (none)");
+ expect(result).toContain("- **agent version**: (none)");
+ });
+
+ it("should render non-empty arrays as sub-bullet lists", () => {
+ const obj = { allowed_domains: ["example.com", "github.com"] };
+ const result = jsonObjectToMarkdown(obj);
+ expect(result).toContain("- **allowed domains**:");
+ expect(result).toContain(" - example.com");
+ expect(result).toContain(" - github.com");
+ });
+
+ it("should render empty arrays as (none)", () => {
+ const obj = { allowed_domains: [] };
+ const result = jsonObjectToMarkdown(obj);
+ expect(result).toContain("- **allowed domains**: (none)");
+ });
+
+ it("should render nested objects as indented sub-bullet lists", () => {
+ const obj = { steps: { firewall: "iptables" } };
+ const result = jsonObjectToMarkdown(obj);
+ expect(result).toContain("- **steps**:");
+ expect(result).toContain(" - **firewall**: iptables");
+ });
+
+ it("should return empty string for null or non-object input", () => {
+ expect(jsonObjectToMarkdown(null)).toBe("");
+ expect(jsonObjectToMarkdown(undefined)).toBe("");
+ expect(jsonObjectToMarkdown([])).toBe("");
+ });
+
+ it("should handle numeric values", () => {
+ const obj = { run_id: 12345, run_number: 7 };
+ const result = jsonObjectToMarkdown(obj);
+ expect(result).toContain("- **run id**: 12345");
+ expect(result).toContain("- **run number**: 7");
+ });
+});