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
13 changes: 10 additions & 3 deletions actions/setup/js/add_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_help
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { getMissingInfoSections } = require("./missing_messages_helper.cjs");
const { getMessages } = require("./messages_core.cjs");
const { getBodyHeader } = require("./messages_header.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { MAX_COMMENT_LENGTH, MAX_MENTIONS, MAX_LINKS, enforceCommentLimits } = require("./comment_limit_helpers.cjs");
const { resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs");
Expand Down Expand Up @@ -582,9 +583,15 @@ async function main(config = {}) {

// Inject CAUTION at top of body if threat detection warning was raised
const detectionCaution = getDetectionCautionAlert(workflowName, runUrl);
if (detectionCaution) {
processedBody = detectionCaution + "\n\n" + processedBody;
}

// Inject body header if configured (placed after caution, before user content)
const bodyHeader = getBodyHeader({ workflowName, runUrl });

// Build prefix: caution (if any) → body header (if any) → user content
let prefix = "";
if (detectionCaution) prefix += detectionCaution + "\n\n";
if (bodyHeader) prefix += bodyHeader + "\n\n";
if (prefix) processedBody = prefix + processedBody;

// Add tracker ID and footer
const trackerIDComment = getTrackerID("markdown");
Expand Down
8 changes: 8 additions & 0 deletions actions/setup/js/create_discussion.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { ERR_VALIDATION } = require("./error_codes.cjs");
const { createExpirationLine, generateFooterWithExpiration, addExpirationToFooter } = require("./ephemerals.cjs");
const { generateFooterWithMessages, getDetectionCautionAlert } = require("./messages_footer.cjs");
const { getBodyHeader } = require("./messages_header.cjs");
const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
Expand Down Expand Up @@ -515,7 +516,14 @@ async function main(config = {}) {
const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || "";
const runUrl = buildWorkflowRunUrl(context, context.repo);

// Inject body header before user content (unshifted first, so caution will appear before it)
const bodyHeader = getBodyHeader({ workflowName, runUrl });
if (bodyHeader) {
bodyLines.unshift(...bodyHeader.split("\n"), "");
}

// Inject CAUTION at top of body if threat detection warning was raised
// (unshifted after header so it appears first in the final output)
const detectionCaution = getDetectionCautionAlert(workflowName, runUrl);
if (detectionCaution) {
bodyLines.unshift(...detectionCaution.split("\n"), "");
Expand Down
8 changes: 8 additions & 0 deletions actions/setup/js/create_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { generateFooterWithMessages, getDetectionCautionAlert } = require("./messages_footer.cjs");
const { getBodyHeader } = require("./messages_header.cjs");
const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { getTrackerID } = require("./get_tracker_id.cjs");
Expand Down Expand Up @@ -447,7 +448,14 @@ async function main(config = {}) {
const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID ?? "";
const runUrl = buildWorkflowRunUrl(context, context.repo);

// Inject body header before user content (unshifted first, so caution will appear before it)
const bodyHeader = getBodyHeader({ workflowName, runUrl });
if (bodyHeader) {
bodyLines.unshift(...bodyHeader.split("\n"), "");
}

// Inject CAUTION at top of body if threat detection warning was raised
// (unshifted after header so it appears first in the final output)
const detectionCaution = getDetectionCautionAlert(workflowName, runUrl);
if (detectionCaution) {
bodyLines.unshift(...detectionCaution.split("\n"), "");
Expand Down
9 changes: 9 additions & 0 deletions actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const { addExpirationToFooter } = require("./ephemerals.cjs");
const { generateWorkflowIdMarker } = require("./generate_footer.cjs");
const { parseBoolTemplatable } = require("./templatable.cjs");
const { generateFooterWithMessages, getDetectionCautionAlert } = require("./messages_footer.cjs");
const { getBodyHeader } = require("./messages_header.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { normalizeBranchName } = require("./normalize_branch_name.cjs");
const { pushExtraEmptyCommit } = require("./extra_empty_commit.cjs");
Expand Down Expand Up @@ -1126,6 +1127,14 @@ async function main(config = {}) {
// Prepend threat detection caution alert at the very top of the PR body so it is
// immediately visible to reviewers. The caution is omitted from the footer to
// avoid duplication (skipDetectionCaution is passed to generateFooterWithMessages).

// Inject body header before user content (unshifted first, so caution will appear before it)
const bodyHeader = getBodyHeader({ workflowName, runUrl });
if (bodyHeader) {
bodyLines.unshift(...bodyHeader.split("\n"), "");
}

// Inject CAUTION at top of body (unshifted after header so it appears first in the final output)
const detectionCaution = getDetectionCautionAlert(workflowName, runUrl);
if (detectionCaution) {
// unshift(caution, "", "") places the caution alert at index 0 and two blank
Expand Down
4 changes: 4 additions & 0 deletions actions/setup/js/messages.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const { getRunStartedMessage, getRunSuccessMessage, getRunFailureMessage } = req
// Re-export close discussion messages
const { getCloseOlderDiscussionMessage } = require("./messages_close_discussion.cjs");

// Re-export body header messages
const { getBodyHeader } = require("./messages_header.cjs");

module.exports = {
getMessages,
renderTemplate,
Expand All @@ -52,4 +55,5 @@ module.exports = {
getRunSuccessMessage,
getRunFailureMessage,
getCloseOlderDiscussionMessage,
getBodyHeader,
};
1 change: 1 addition & 0 deletions actions/setup/js/messages_core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const fs = require("fs");
* @property {string} [agentFailureIssue] - Custom footer template for agent failure tracking issues
* @property {string} [agentFailureComment] - Custom footer template for comments on agent failure tracking issues
* @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated
* @property {string} [bodyHeader] - Custom header text prepended to every message body (issues, comments, PRs, discussions). Placeholders: {workflow_name}, {run_url}
* @property {boolean} [appendOnlyComments] - If true, create new comments instead of updating the activation comment
* @property {string|boolean} [activationComments] - If false or "false", disable all activation/fallback comments entirely. Supports templatable boolean values (default: true)
*/
Expand Down
38 changes: 38 additions & 0 deletions actions/setup/js/messages_header.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* Body Header Message Module
*
* This module provides the body-header generation for safe-output workflows.
* The body header is prepended to every message body generated by safe outputs
* (issues, comments, pull requests, discussions).
*/

const { getMessages, renderTemplate, toSnakeCase } = require("./messages_core.cjs");

/**
* @typedef {Object} BodyHeaderContext
* @property {string} workflowName - Name of the workflow
* @property {string} runUrl - URL of the workflow run
*/

/**
* Get the body header text, using the custom template if configured.
* Returns an empty string when no body-header is configured.
* @param {BodyHeaderContext} ctx - Context for header generation
* @returns {string} The rendered header text, or empty string if not configured
*/
function getBodyHeader(ctx) {
const messages = getMessages();
if (!messages?.bodyHeader) {
return "";
}

const templateContext = toSnakeCase(ctx);
return renderTemplate(messages.bodyHeader, templateContext);
}

module.exports = {
getBodyHeader,
};
76 changes: 76 additions & 0 deletions actions/setup/js/messages_header.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @ts-check
import { describe, it, expect, beforeEach, vi } from "vitest";

// messages_core.cjs calls core.warning on parse failures - provide a stub
const mockCore = {
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
setFailed: vi.fn(),
setOutput: vi.fn(),
};
global.core = mockCore;

const { getBodyHeader } = require("./messages_header.cjs");

const WORKFLOW = "My Workflow";
const RUN_URL = "https://github.com/owner/repo/actions/runs/99";

describe("messages_header", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
});

describe("getBodyHeader", () => {
it("returns empty string when no body-header is configured", () => {
const result = getBodyHeader({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(result).toBe("");
});

it("returns rendered template when body-header is configured", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
bodyHeader: "> ⚠️ Generated by [{workflow_name}]({run_url}).",
});

const result = getBodyHeader({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(result).toBe(`> ⚠️ Generated by [${WORKFLOW}](${RUN_URL}).`);
});

it("supports snake_case placeholders", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
bodyHeader: "> Generated by {workflow_name}.",
});

const result = getBodyHeader({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(result).toBe(`> Generated by ${WORKFLOW}.`);
});

it("supports camelCase placeholders", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
bodyHeader: "> Run: {runUrl}",
});

const result = getBodyHeader({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(result).toBe(`> Run: ${RUN_URL}`);
});

it("returns empty string when messages env is set but body-header is absent", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
footer: "> Custom footer",
});

const result = getBodyHeader({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(result).toBe("");
});

it("leaves unknown placeholders unchanged", () => {
process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
bodyHeader: "> Header {workflow_name} {unknown_placeholder}",
});

const result = getBodyHeader({ workflowName: WORKFLOW, runUrl: RUN_URL });
expect(result).toBe(`> Header ${WORKFLOW} {unknown_placeholder}`);
});
});
});
5 changes: 5 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8621,6 +8621,11 @@
"description": "Custom message template for commit push link appended to the activation comment. Available placeholders: {commit_sha}, {short_sha}, {commit_url}. Default: 'Commit pushed: [`{short_sha}`]({commit_url})'",
"examples": ["Commit pushed: [`{short_sha}`]({commit_url})", "[`{short_sha}`]({commit_url}) pushed"]
},
"body-header": {
"type": "string",
"description": "Custom header text prepended to every message body generated by safe outputs (issues, comments, pull requests, discussions). Applied after any threat-detection caution alert and before the agent-generated content. Available placeholders: {workflow_name}, {run_url}.",
"examples": ["> ⚠️ This content was generated by [{workflow_name}]({run_url}).", "> 🤖 AI-generated output — please review before acting."]
},
"append-only-comments": {
"type": "boolean",
"description": "When enabled, workflow completion notifier creates a new comment instead of editing the activation comment. Creates an append-only timeline of workflow runs. Default: false",
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ type SafeOutputMessagesConfig struct {
CommitPushed string `yaml:"commit-pushed,omitempty" json:"commitPushed,omitempty"` // Custom message template for commit push link. Placeholders: {commit_sha}, {short_sha}, {commit_url}
AgentFailureIssue string `yaml:"agent-failure-issue,omitempty" json:"agentFailureIssue,omitempty"` // Custom footer template for agent failure tracking issues
AgentFailureComment string `yaml:"agent-failure-comment,omitempty" json:"agentFailureComment,omitempty"` // Custom footer template for comments on agent failure tracking issues
BodyHeader string `yaml:"body-header,omitempty" json:"bodyHeader,omitempty"` // Custom header text prepended to every message body (issues, comments, PRs, discussions). Placeholders: {workflow_name}, {run_url}
}

// MentionsConfig holds configuration for @mention filtering in safe outputs
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/safe_outputs_messages_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func parseMessagesConfig(messagesMap map[string]any) *SafeOutputMessagesConfig {
config.CommitPushed = extractStringFromMap(messagesMap, "commit-pushed", nil)
config.AgentFailureIssue = extractStringFromMap(messagesMap, "agent-failure-issue", nil)
config.AgentFailureComment = extractStringFromMap(messagesMap, "agent-failure-comment", nil)
config.BodyHeader = extractStringFromMap(messagesMap, "body-header", nil)

return config
}
Expand Down
53 changes: 53 additions & 0 deletions pkg/workflow/safe_outputs_messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,31 @@ func TestSafeOutputsMessagesConfiguration(t *testing.T) {
t.Error("Expected Messages to be nil when not configured")
}
})

t.Run("Should parse body-header message", func(t *testing.T) {
frontmatter := map[string]any{
"name": "Test Workflow",
"safe-outputs": map[string]any{
"create-issue": nil,
"messages": map[string]any{
"body-header": "> ⚠️ Generated by [{workflow_name}]({run_url}).",
},
},
}

config := compiler.extractSafeOutputsConfig(frontmatter)
if config == nil {
t.Fatal("Expected SafeOutputsConfig to be parsed")
}

if config.Messages == nil {
t.Fatal("Expected Messages to be parsed")
}

if config.Messages.BodyHeader != "> ⚠️ Generated by [{workflow_name}]({run_url})." {
t.Errorf("Expected BodyHeader to be custom template, got %q", config.Messages.BodyHeader)
}
})
}

func TestSerializeMessagesConfig(t *testing.T) {
Expand Down Expand Up @@ -192,4 +217,32 @@ func TestSerializeMessagesConfig(t *testing.T) {
t.Errorf("Expected Footer to be preserved, got %q", parsed.Footer)
}
})

t.Run("Should serialize body-header to camelCase JSON key", func(t *testing.T) {
config := &SafeOutputMessagesConfig{
BodyHeader: "> ⚠️ AI-generated content from {workflow_name}",
}

result, err := serializeMessagesConfig(config)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}

var parsed SafeOutputMessagesConfig
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
t.Fatalf("Result is not valid JSON: %v", err)
}

if parsed.BodyHeader != "> ⚠️ AI-generated content from {workflow_name}" {
t.Errorf("Expected BodyHeader to be preserved, got %q", parsed.BodyHeader)
}

if !strings.Contains(result, `"bodyHeader"`) {
t.Errorf("Expected JSON to contain camelCase key 'bodyHeader', got: %s", result)
}

if strings.Contains(result, `"BodyHeader"`) {
t.Errorf("Expected JSON to NOT contain PascalCase key 'BodyHeader', got: %s", result)
}
})
}
Loading