Skip to content
Closed
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
2 changes: 1 addition & 1 deletion actions/setup-cli/install.sh

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

28 changes: 28 additions & 0 deletions docs/src/content/docs/reference/footers.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,34 @@ The `footer` field accepts `"always"` (default), `"none"`, or `"if-body"` (foote

The default value for `footer` is `true`. To hide footers, explicitly set `footer: false`.

## AI Authorship Disclosure Footer

Use `disclosure-footer: true` to automatically append a standard AI authorship disclosure to every safe output (issues, PRs, discussions, comments). This is the recommended way to inform readers that content was AI-generated and posted under a maintainer's token:

```yaml wrap
safe-outputs:
disclosure-footer: true
create-issue:
add-comment:
```

The built-in disclosure text is:

```
> 🤖 **Automated content by GitHub Copilot.** Posted via a maintainer's GitHub token, so it appears under their account — the account owner did **not** write or approve this content personally. Generated by the [{workflow_name}]({agentic_workflow_url}) workflow.{ai_credits_suffix} · [◷]({history_link})
```

To use a custom disclosure template instead of the built-in text:

```yaml wrap
safe-outputs:
disclosure-footer:
template: "> 🤖 AI-generated by [{workflow_name}]({agentic_workflow_url}).{ai_credits_suffix} · [◷]({history_link})"
create-issue:
```

`disclosure-footer` sets `messages.footer` under the hood. If you also set `messages.footer` explicitly, the explicit value takes precedence.

## Customizing Footer Messages

Instead of hiding footers entirely, you can customize the footer message text using the `messages.footer` template. This allows you to maintain attribution while using custom branding:
Expand Down
31 changes: 31 additions & 0 deletions pkg/workflow/safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,37 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut
}
}

// Handle disclosure-footer configuration (shorthand for standard AI authorship disclosure footer).
// Accepted shapes:
// - true: use the built-in standard disclosure footer text
// - {template: "..."}: use a custom disclosure footer text
// disclosure-footer only sets messages.footer when a footer template is not already explicitly
// provided via messages.footer, so an explicit messages.footer always takes precedence.
if disclosureFooter, exists := outputMap["disclosure-footer"]; exists {
switch v := disclosureFooter.(type) {
case bool:
if v {
if config.Messages == nil {
config.Messages = &SafeOutputMessagesConfig{}
}
if config.Messages.Footer == "" {
config.Messages.Footer = DefaultDisclosureFooter
safeOutputsConfigLog.Print("Applied built-in disclosure footer via disclosure-footer: true")
}
}
case map[string]any:
if tmpl, ok := v["template"].(string); ok && tmpl != "" {
if config.Messages == nil {
config.Messages = &SafeOutputMessagesConfig{}
}
if config.Messages.Footer == "" {
config.Messages.Footer = tmpl
safeOutputsConfigLog.Print("Applied custom disclosure footer via disclosure-footer.template")
}
}
}
}

// Handle activation-comments at safe-outputs top level (templatable boolean)
if err := preprocessBoolFieldAsString(outputMap, "activation-comments", safeOutputsConfigLog); err != nil {
safeOutputsConfigLog.Printf("activation-comments: %v", err)
Expand Down
5 changes: 5 additions & 0 deletions pkg/workflow/safe_outputs_messages_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import (

var safeOutputMessagesLog = logger.New("workflow:safe_outputs_config_messages")

// DefaultDisclosureFooter is the built-in AI authorship disclosure footer text used when
// disclosure-footer: true is set in safe-outputs frontmatter. It clearly identifies
// AI-generated content and explains that it was posted under a maintainer's token.
const DefaultDisclosureFooter = "> 🤖 **Automated content by GitHub Copilot.** Posted via a maintainer's GitHub token, so it appears under their account — the account owner did **not** write or approve this content personally. Generated by the [{workflow_name}]({agentic_workflow_url}) workflow.{ai_credits_suffix} · [◷]({history_link})"

// ========================================
// Safe Output Messages Configuration
// ========================================
Expand Down
129 changes: 129 additions & 0 deletions pkg/workflow/safe_outputs_messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,135 @@ func TestSafeOutputsMessagesConfiguration(t *testing.T) {
})
}

func TestDisclosureFooter(t *testing.T) {
compiler := NewCompiler()

t.Run("disclosure-footer: true sets built-in standard footer", func(t *testing.T) {
frontmatter := map[string]any{
"name": "Test Workflow",
"safe-outputs": map[string]any{
"create-issue": nil,
"disclosure-footer": true,
},
}

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

if config.Messages == nil {
t.Fatal("Expected Messages to be non-nil when disclosure-footer is true")
}

if config.Messages.Footer != DefaultDisclosureFooter {
t.Errorf("Expected Footer to be the built-in disclosure footer, got %q", config.Messages.Footer)
}
})

t.Run("disclosure-footer: true does not override explicit messages.footer", func(t *testing.T) {
customFooter := "> Custom footer by [{workflow_name}]({run_url})"
frontmatter := map[string]any{
"name": "Test Workflow",
"safe-outputs": map[string]any{
"create-issue": nil,
"disclosure-footer": true,
"messages": map[string]any{
"footer": customFooter,
},
},
}

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.Footer != customFooter {
t.Errorf("Expected explicit messages.footer to win over disclosure-footer, got %q", config.Messages.Footer)
}
})

t.Run("disclosure-footer: false does not set footer", func(t *testing.T) {
frontmatter := map[string]any{
"name": "Test Workflow",
"safe-outputs": map[string]any{
"create-issue": nil,
"disclosure-footer": false,
},
}

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

if config.Messages != nil && config.Messages.Footer != "" {
t.Errorf("Expected no footer when disclosure-footer is false, got %q", config.Messages.Footer)
}
})

t.Run("disclosure-footer with template sets custom footer", func(t *testing.T) {
customTemplate := "> 🤖 AI-generated by [{workflow_name}]({run_url})"
frontmatter := map[string]any{
"name": "Test Workflow",
"safe-outputs": map[string]any{
"create-issue": nil,
"disclosure-footer": map[string]any{
"template": customTemplate,
},
},
}

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

if config.Messages == nil {
t.Fatal("Expected Messages to be non-nil when disclosure-footer.template is set")
}

if config.Messages.Footer != customTemplate {
t.Errorf("Expected Footer to be the custom template, got %q", config.Messages.Footer)
}
})

t.Run("disclosure-footer template does not override explicit messages.footer", func(t *testing.T) {
customFooter := "> Explicit footer"
customTemplate := "> Disclosure template"
frontmatter := map[string]any{
"name": "Test Workflow",
"safe-outputs": map[string]any{
"create-issue": nil,
"disclosure-footer": map[string]any{
"template": customTemplate,
},
"messages": map[string]any{
"footer": customFooter,
},
},
}

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.Footer != customFooter {
t.Errorf("Expected explicit messages.footer to win over disclosure-footer template, got %q", config.Messages.Footer)
}
})
}

func TestSerializeMessagesConfig(t *testing.T) {
t.Run("Should serialize nil config to empty string", func(t *testing.T) {
result, err := serializeMessagesConfig(nil)
Expand Down